Initial commit

This commit is contained in:
Degradin 2025-04-15 10:00:00 +03:00
commit 7c111dd711
106 changed files with 22922 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

49
.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# Environment variables
.env
.env.local
.env.*.local
# Dependencies
/node_modules
/.pnp
.pnp.js
# Production
/build
/dist
# Misc
.DS_Store
*.pem
.vscode
.idea
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
# Logs
logs
*.log
pnpm-debug.log*
lerna-debug.log*
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/supabase/.temp

187
README.md Normal file
View File

@ -0,0 +1,187 @@
# CampFire Critics
CampFire Critics — это веб-приложение, созданное с использованием React, Vite и Tailwind CSS, предназначенное для каталогизации медиаконтента (фильмов, сериалов, игр, аниме) и управления пользовательскими отзывами. Приложение включает в себя систему аутентификации, подробные страницы медиа, профили пользователей, систему достижений, систему поддержки и административную панель для модерации контента и управления данными. В качестве бэкенда используется PocketBase.
## Функции
* **Аутентификация пользователей**: Полная система регистрации, входа, выхода и сброса пароля с использованием PocketBase Auth.
* **Профили пользователей**: Просмотр профилей пользователей, отображение их статистики (количество рецензий, средний рейтинг, XP, уровень), списка достижений и витрины избранных рецензий. Возможность редактирования собственного профиля (описание, аватар, баннер, витрина).
* **Каталог медиа**: Просмотр списка медиа с фильтрацией по типу (фильмы, сериалы, игры, аниме) и поиском.
* **Страницы обзора медиа**: Подробные страницы для каждого медиа, включающие информацию о нем, список сезонов (для сериалов/аниме), а также раздел с пользовательскими рецензиями и рейтингами.
* **Система рецензий**: Пользователи могут оставлять рецензии на медиа или отдельные сезоны, выставлять оценки по нескольким характеристикам, указывать прогресс просмотра/прохождения и отмечать спойлеры. Рецензии отображаются на страницах медиа/сезонов и в профилях пользователей.
* **Система лайков рецензий**: Пользователи могут ставить лайки рецензиям других пользователей.
* **Система XP и уровней**: Пользователи получают XP за создание рецензий и получение достижений, что повышает их уровень. Уровень и XP отображаются в профиле.
* **Система достижений**: Пользователи могут получать достижения за различные действия. Список достижений отображается в профиле.
* **Система поддержки**: Пользователи могут создавать тикеты поддержки с выбором категории и описанием проблемы.
* **Административная панель**: Раздел для администраторов с возможностью управления медиа, пользователями, сезонами, достижениями и тикетами поддержки. Включает дашборд с общей статистикой.
* **Адаптивный дизайн**: Приложение адаптировано для корректного отображения на различных устройствах с использованием Tailwind CSS.
## Технологии
* **Фронтенд**:
* React: Библиотека для построения пользовательских интерфейсов.
* Vite: Быстрый сборщик фронтенда.
* Tailwind CSS: Утилитарный CSS-фреймворк для быстрой стилизации.
* React Router DOM: Для маршрутизации в приложении.
* `react-icons`: Набор популярных иконок.
* `chart.js` и `react-chartjs-2`: Для построения графиков рейтингов.
* `react-datepicker`: Для выбора дат.
* `react-fast-marquee`: Для бегущей строки (если используется).
* `react-quill`: WYSIWYG редактор для контента рецензий/описаний.
* `react-select`: Кастомизируемый компонент выбора.
* `react-toastify`: Для уведомлений (тостов).
* `dompurify`: Для очистки HTML контента.
* `uuid`: Для генерации уникальных ID.
* **Бэкенд**:
* PocketBase: Опенсорсный бэкенд в одном файле (Go) с базой данных SQLite, системой аутентификации, файловым хранилищем, realtime подписками и хуками.
* **Внешние API**:
* TMDB (The Movie Database): Используется для получения информации о фильмах, сериалах и, возможно, другом медиаконтенте (подразумевается, исходя из предыдущего README).
## Настройка проекта
1. **Клонирование репозитория** (стандартный шаг, не применимо в WebContainer):
```bash
git clone https://github.com/your-username/campfire-critics.git
cd campfire-critics
```
2. **Установка зависимостей**:
```bash
npm install
```
3. **Настройка PocketBase**:
* Скачайте и запустите PocketBase с [официального сайта](https://pocketbase.io/docs/getting-started/).
* Перейдите в Admin UI (обычно `http://127.0.0.1:8090/_/`).
* Создайте следующие коллекции (убедитесь, что включены необходимые поля и связи):
* `users` (тип `auth`): Стандартная коллекция пользователей PocketBase. Добавьте поля: `role` (select, options: `user`, `admin`), `is_critic` (bool), `description` (text), `profile_picture` (file), `banner_picture` (file), `review_count` (number, default 0), `average_rating` (number, default 0), `xp` (number, default 0), `level` (number, default 1), `showcase` (relation to `reviews`, multiple).
* `media`: Поля: `title` (text), `type` (select, options: `movie`, `tv`, `game`, `anime`), `path` (text, unique), `overview` (text), `release_date` (date), `poster` (file), `backdrop` (file), `trailer_url` (url), `characteristics` (json), `average_rating` (number, default 0), `review_count` (number, default 0), `is_published` (bool, default false), `is_popular` (bool, default false), `progress_type` (select, options: `percentage`, `episodes`, `chapters`, `none`), `created_by` (relation to `users`), `created` (datetime), `updated` (datetime).
* `seasons`: Поля: `media_id` (relation to `media`, required), `season_number` (number, required), `title` (text), `overview` (text), `release_date` (date), `poster` (file), `average_rating` (number, default 0), `review_count` (number, default 0), `is_published` (bool, default false), `created_by` (relation to `users`), `created` (datetime), `updated` (datetime).
* `reviews`: Поля: `user_id` (relation to `users`, required), `media_id` (relation to `media`, required), `season_id` (relation to `seasons`, optional), `content` (text), `ratings` (json), `overall_rating` (number, default 0), `has_spoilers` (bool, default false), `progress` (number), `created` (datetime), `updated` (datetime).
* `achievements`: Поля: `name` (text), `description` (text), `icon` (file), `xp_reward` (number, default 0), `created` (datetime), `updated` (datetime).
* `user_achievements`: Поля: `user_id` (relation to `users`, required), `achievement_id` (relation to `achievements`, required), `awarded_at` (datetime), `awarded_by` (relation to `users`, optional), `created` (datetime), `updated` (datetime).
* `review_likes`: Поля: `review_id` (relation to `reviews`, required), `user_id` (relation to `users`, required), `created` (datetime).
* `support_tickets`: Поля: `user_id` (relation to `users`, required), `category` (select, options: `bug`, `feature_request`, `question`, `other`), `subject` (text), `message` (text), `status` (select, options: `open`, `in_progress`, `closed`, default `open`), `admin_notes` (text), `created` (datetime), `updated` (datetime).
* **Настройте RLS (Row Level Security)** для каждой коллекции, чтобы определить, кто может просматривать, создавать, обновлять и удалять записи.
* **Настройте Hooks/Triggers** в PocketBase для автоматического обновления полей `average_rating` и `review_count` в коллекциях `media`, `seasons` и `users` при создании, обновлении или удалении записей в коллекциях `reviews` и `review_likes`. Также настройте хуки для обновления `xp` и `level` пользователя при создании рецензии или получении достижения. *Это критически важный шаг для корректной работы статистики.*
4. **Настройте переменные окружения**:
* Создайте файл `.env` в корне проекта, если его нет.
* Добавьте URL вашего PocketBase инстанса:
```
VITE_POCKETBASE_URL=http://127.0.0.1:8090
```
Замените URL на адрес вашего запущенного PocketBase.
5. **Запустите сервер разработки**:
```bash
npm run dev
```
Приложение будет доступно по адресу, указанному Vite (обычно `http://localhost:5173`).
## Структура проекта и описание модулей
Проект организован следующим образом:
```
src/
├── App.jsx # Основной компонент приложения, определяет маршруты.
├── main.jsx # Точка входа в приложение, инициализирует React и оборачивает App в контекст-провайдеры и роутер.
├── index.css # Основной файл стилей, импортирует Tailwind CSS.
├── pages/ # Компоненты, представляющие целые страницы приложения.
│ ├── HomePage.jsx # Главная страница с обзорами и статистикой.
│ ├── CatalogPage.jsx # Страница каталога медиа.
│ ├── MediaOverviewPage.jsx# Страница с подробным обзором конкретного медиа.
│ ├── ProfilePage.jsx # Страница профиля пользователя.
│ ├── RatingPage.jsx # Страница с рейтингами пользователей (по рецензиям, по уровню).
│ ├── SupportPage.jsx # Страница для создания тикетов поддержки.
│ ├── NotFoundPage.jsx # Страница 404.
│ ├── LoginPage.jsx # Страница входа.
│ ├── RegisterPage.jsx # Страница регистрации.
│ ├── ForgotPasswordPage.jsx# Страница сброса пароля.
│ └── admin/ # Страницы административной панели.
│ ├── AdminDashboard.jsx
│ ├── AdminMediaPage.jsx
│ ├── AdminUsersPage.jsx
│ ├── AdminSeasonsPage.jsx
│ ├── AdminAchievementsPage.jsx
│ ├── AdminTicketsPage.jsx
│ └── AdminTicketDetailPage.jsx
├── components/ # Переиспользуемые UI-компоненты.
│ ├── auth/ # Компоненты и логика, связанные с аутентификацией (AuthRoute, GuestRoute).
│ ├── layout/ # Компоненты макета (Header, Footer, Layout).
│ ├── admin/ # Компоненты для административной панели (AdminLayout).
│ ├── MediaCard.jsx # Карточка медиа для каталога/списков.
│ ├── ReviewCard.jsx # Карточка рецензии.
│ ├── RatingChart.jsx # Компонент графика рейтинга.
│ ├── SearchBar.jsx # Компонент поиска.
│ ├── UserRankCard.jsx # Карточка пользователя для рейтингов.
│ ├── AchievementCard.jsx # Карточка достижения.
│ ├── SupportTicketCard.jsx# Карточка тикета поддержки.
│ └── ... другие компоненты
├── contexts/ # React Context провайдеры для глобального состояния.
│ ├── AuthContext.jsx # Управляет состоянием аутентификации пользователя.
│ └── ProfileActionsContext.jsx # Управляет действиями, связанными с профилем (например, витрина).
├── services/ # Утилиты и сервисы для взаимодействия с API/бэкендом.
│ └── pocketbaseService.js # **Основной модуль для взаимодействия с PocketBase API.**
├── assets/ # Статические файлы (изображения, шрифты и т.д.).
└── ... другие файлы конфигурации (tailwind.config.js, postcss.config.js, vite.config.js)
```
### Описание ключевых модулей:
* **`src/services/pocketbaseService.js`**:
Этот файл является центральным слоем для всех операций взаимодействия с бэкендом PocketBase. Он инициализирует PocketBase SDK и экспортирует функции для:
* **Аутентификации**: `signUp`, `signIn`, `signOut`, `getCurrentUser`, `requestPasswordReset`.
* **Пользователей**: `getUserProfile`, `getUserProfileByUsername`, `updateUserProfile`, `updateUserShowcase`, `listUsersRankedByReviews`, `listUsersRankedByLevel`.
* **Медиа**: `createMedia`, `getMediaByPath`, `getMediaById`, `listMedia`, `listPopularMedia`, `updateMedia`, `deleteMedia`, `searchMedia`.
* **Рецензий**: `getLatestReviews`, `getReviewsByMediaId`, `getReviewsByUserId`, `getUserReviewForMedia`, `createReview`, `updateReview`, `deleteReview`.
* **Сезонов**: `getSeasonsByMediaId`, `getSeasonById`, `createSeason`, `updateSeason`, `deleteSeason`.
* **Достижений**: `getAllAchievements`, `getUserAchievements`, `awardAchievement`.
* **Лайков рецензий**: `likeReview`, `unlikeReview`, `getReviewLikeCount`, `hasUserLikedReview`.
* **Тикетов поддержки**: `createSupportTicket`, `getUserTickets`, `getAllTickets`, `getTicketById`, `updateSupportTicket`.
* **Файлового хранилища**: `uploadFile`, `deleteFile`, `getFileUrl`.
* **Утилит**: `calculateLevel`, `getXpForNextLevel`, `getXpForCurrentLevel`, `validateMediaData`, `formatMediaData`, `updateRatingStats` (функция для пересчета средних рейтингов и количества рецензий, часто вызывается после операций с рецензиями).
Этот модуль абстрагирует логику работы с API, делая компоненты более чистыми.
* **`src/contexts/AuthContext.jsx`**:
Предоставляет глобальный доступ к состоянию аутентификации текущего пользователя. Использует `pocketbaseService` для выполнения операций входа/выхода/регистрации и хранит информацию о пользователе в состоянии React Context. Компоненты могут использовать хук `useAuth` для доступа к данным пользователя и функциям аутентификации.
* **`src/contexts/ProfileActionsContext.jsx`**:
Предоставляет контекст для управления действиями, специфичными для страницы профиля, такими как обновление витрины рецензий.
* **`src/App.jsx`**:
Определяет структуру маршрутизации приложения с использованием `react-router-dom`. Здесь настраиваются публичные маршруты, маршруты только для гостей (`GuestRoute`) и защищенные маршруты (`AuthRoute`), включая маршруты административной панели.
* **`src/components/auth/AuthRoute.jsx` и `src/components/auth/GuestRoute.jsx`**:
Вспомогательные компоненты маршрутизации, которые используют `AuthContext` для проверки статуса аутентификации пользователя и его роли, перенаправляя пользователя на другие страницы при необходимости (например, перенаправление неаутентифицированных пользователей с защищенных маршрутов или аутентифицированных пользователей со страниц входа/регистрации).
* **`src/pages/` и `src/components/`**:
Эти директории содержат компоненты пользовательского интерфейса. `pages` содержат компоненты верхнего уровня, представляющие целые страницы, а `components` содержат более мелкие, переиспользуемые блоки UI. Стилизация в основном выполняется с помощью классов Tailwind CSS.
* **Административная панель (`src/pages/admin/`, `src/components/admin/AdminLayout.jsx`)**:
Набор страниц и компонентов, доступных только пользователям с ролью 'admin'. `AdminLayout` предоставляет общую структуру макета для всех страниц админки (навигация, заголовок). Страницы внутри `pages/admin/` используют функции из `pocketbaseService` для управления данными (создание, чтение, обновление, удаление) медиа, пользователей, сезонов, достижений и тикетов поддержки.
## Вклад в проект
1. Откройте issue для обсуждения новой функции или исправления.
2. Сделайте fork репозитория.
3. Создайте новую ветку (`git checkout -b feature-name`).
4. Зафиксируйте изменения.
5. Запушьте ветку (`git push origin feature-name`).
6. Создайте Pull Request.
## Требования
* Node.js 18+
* npm 8+
* Запущенный инстанс PocketBase с настроенными коллекциями, RLS и хуками.
## Лицензия
MIT License. Подробнее см. [LICENSE](LICENSE).

41
eslint.config.js Normal file
View File

@ -0,0 +1,41 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
process: 'readonly'
},
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<!-- Updated favicon link -->
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CampFire мнеие</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

16
jsrepo.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/jsrepo@2.0.3/schemas/project-config.json",
"repos": [
"https://reactbits.dev/tailwind"
],
"includeTests": false,
"watermark": true,
"configFiles": {},
"paths": {
"*": "./src/components/reactbits",
"Animations": "./src/components/reactbits/Animations",
"TextAnimations": "./src/components/reactbits/TextAnimations",
"Backgrounds": "./src/components/reactbits/Backgrounds",
"Components": "./src/components/reactbits/Components"
}
}

6590
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "campfirecritics",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"chart.js": "^4.4.3",
"dompurify": "^3.2.5",
"framer-motion": "^11.18.2",
"gsap": "^3.13.0",
"pocketbase": "^0.21.3",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-datepicker": "^8.3.0",
"react-dom": "^18.2.0",
"react-fast-marquee": "^1.6.4",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0",
"react-quill": "^2.0.0",
"react-router-dom": "^6.23.1",
"react-select": "^5.10.1",
"react-toastify": "^10.0.5",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"vite": "^5.2.0"
}
}

186
pb_schema.txt Normal file
View File

@ -0,0 +1,186 @@
users
id
Nonempty
Hidden
password
Nonempty
Hidden
tokenKey
Nonempty
email
emailVisibility
verified
username
profile_picture
Single
role
user, moderator, admin
Single
showcase
reviews
Multiple
is_critic
description
banner_picture
Single
review_count
average_rating
balance
level
xp
created
Create
updated
Create/Update
achievements
id
title
description
xp_reward
icon
Single
created
Create
updated
Create/Update
media
id
path
title
type
movie, tv, game, anime
Single
overview
release_date
poster
Single
backdrop
Single
is_published
created_by
users
Single
average_rating
review_count
is_popular
characteristics
progress_type
created
Create
updated
Create/Update
review_likes
Nonempty
id
review_id
reviews
Single
user_id
users
Single
created
Create
updated
Create/Update
reviews
Nonempty
id
user_id
users
Single
media_id
media
Single
season_id
seasons
Single
media_type
movie, tv, game, anime
Single
content
ratings
overall_rating
has_spoilers
status
progress
like_count
created
Create
updated
Create/Update
seasons
Nonempty
id
media_id
media
Single
Nonzero
season_number
title
overview
release_date
poster
Single
average_rating
review_count
is_published
created_by
users
Single
created
Create
updated
Create/Update
support_tickets
Nonempty
id
user_id
users
Single
category
subject
message
status
open, in_progress, closed
Single
admin_notes
created
Create
updated
Create/Update
user_achievements
Nonempty
id
user_id
users
Single
achievement_id
achievements
Single
awarded_by
users
Single
Nonempty
awarded_at
created
Create
updated
Create/Update

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/icon.png Normal file
View File

@ -0,0 +1 @@

1
public/logo.png Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@
https://images.pexels.com/photos/7991579/pexels-photo-7991579.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

120
src/App.jsx Normal file
View File

@ -0,0 +1,120 @@
import React, { useEffect } from 'react';
import { Routes, Route } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProfileActionsProvider } from './contexts/ProfileActionsContext';
import { ClickSparkProvider, useClickSpark } from './contexts/ClickSparkContext';
import ClickSpark from './components/reactbits/Animations/ClickSpark/ClickSpark';
import AuthRoute from './components/auth/AuthRoute';
import GuestRoute from './components/auth/GuestRoute';
import Header from './components/layout/Header';
import Footer from './components/layout/Footer';
import HomePage from './pages/HomePage';
import CatalogPage from './pages/CatalogPage';
import MediaOverviewPage from './pages/MediaOverviewPage';
import ProfilePage from './pages/ProfilePage';
import RatingPage from './pages/RatingPage';
import AdminLayout from './components/admin/AdminLayout';
import AdminDashboard from './pages/admin/AdminDashboard';
import AdminMediaPage from './pages/admin/AdminMediaPage';
import AdminUsersPage from './pages/admin/AdminUsersPage';
import AdminSeasonsPage from './pages/admin/AdminSeasonsPage';
import AdminAchievementsPage from './pages/admin/AdminAchievementsPage';
import AdminSupportPage from './pages/admin/AdminSupportPage';
import NotFoundPage from './pages/NotFoundPage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import SupportTicketForm from './components/support/SupportTicketForm';
import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage';
import TermsOfServicePage from './pages/legal/TermsOfServicePage';
import UserAgreementPage from './pages/legal/UserAgreementPage';
import ProfileSettingsPage from './pages/ProfileSettingsPage';
import AdminSuggestionsPage from './pages/admin/AdminSuggestionsPage';
import AdminRoute from './components/auth/AdminRoute';
import SupportPage from './pages/SupportPage';
import './index.css';
const AppContent = () => {
const { addSpark } = useClickSpark();
useEffect(() => {
const handleClick = (e) => {
console.log('[ClickSpark] Добавление искры:', e.clientX, e.clientY);
addSpark(e.clientX, e.clientY);
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [addSpark]);
return (
<div className="flex flex-col min-h-screen bg-campfire-dark text-campfire-light">
<ClickSpark
sparkColor="#FFA500"
sparkSize={10}
sparkRadius={15}
sparkCount={8}
duration={400}
easing="cubic-bezier(0.4, 0, 0.2, 1)"
extraScale={1.0}
/>
<Header />
<main className="flex-grow pt-16 pb-32">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/catalog" element={<CatalogPage />} />
<Route path="/media/:path" element={<MediaOverviewPage />} />
<Route path="/rating" element={<RatingPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/profile" element={<AuthRoute><ProfilePage /></AuthRoute>} />
<Route path="/profile/:username" element={<ProfilePage />} />
<Route path="/settings" element={<AuthRoute><ProfileSettingsPage /></AuthRoute>} />
<Route path="/auth" element={<GuestRoute />}>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
</Route>
<Route path="/admin" element={<AdminRoute />}>
<Route element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="media" element={<AdminMediaPage />} />
<Route path="media/:mediaId/seasons" element={<AdminSeasonsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="seasons" element={<AdminSeasonsPage />} />
<Route path="achievements" element={<AdminAchievementsPage />} />
<Route path="support" element={<AdminSupportPage />} />
<Route path="suggestions" element={<AdminSuggestionsPage />} />
</Route>
</Route>
<Route path="/support" element={<AuthRoute />}>
<Route index element={<SupportPage />} />
</Route>
<Route path="/privacy-policy" element={<PrivacyPolicyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="/user-agreement" element={<UserAgreementPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<Footer />
</div>
);
};
function App() {
return (
<AuthProvider>
<ProfileActionsProvider>
<ClickSparkProvider>
<AppContent />
</ClickSparkProvider>
</ProfileActionsProvider>
</AuthProvider>
);
}
export default App;

BIN
src/assets/404.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react';
import Marquee from 'react-fast-marquee';
import { getLatestReviews, getFileUrl } from '../services/pocketbaseService'; // Assuming getLatestReviews exists
import { FaFire, FaUserCircle } from 'react-icons/fa'; // Import FaUserCircle
import { Link } from 'react-router-dom'; // Import Link
// Mapping for status values
const statusLabels = {
not_started: 'Не начато',
in_progress: 'В процессе',
completed: 'Завершено',
};
const LatestReviewsMarquee = () => {
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchReviews = async () => {
try {
setLoading(true);
setError(null);
// Ensure expand=users,media is included to get related data
const latestReviews = await getLatestReviews({ expand: 'users,media', sort: '-created', limit: 20 }); // Fetch latest 20 reviews, sorted by creation date descending
setReviews(latestReviews);
console.log('LatestReviewsMarquee: Fetched reviews with expand:', latestReviews);
} catch (err) {
console.error('Error fetching latest reviews:', err);
setError('Не удалось загрузить последние рецензии.');
} finally {
setLoading(false);
}
};
fetchReviews();
}, []); // Empty dependency array means this runs once on mount
if (loading) {
return <div className="text-campfire-ash text-center py-4">Загрузка последних рецензий...</div>;
}
if (error) {
return <div className="text-status-error text-center py-4">{error}</div>;
}
if (!reviews || reviews.length === 0) {
return <div className="text-campfire-ash text-center py-4">Пока нет последних рецензий.</div>;
}
return (
<div className="bg-campfire-charcoal py-4">
<Marquee gradient={false} speed={40} pauseOnHover={true}>
{reviews.map(review => {
// Add robust checks for expanded data
const user = review.expand?.users;
const media = review.expand?.media;
// Skip review if essential expanded data is missing
if (!user || !media || typeof media.characteristics !== 'object') {
console.warn("Skipping review", review.id, "in marquee due to missing user, media, or required fields.");
return null; // Skip rendering this review
}
const username = user.username || 'Анонимный пользователь';
const avatarUrl = user.profile_picture ? getFileUrl(user, 'profile_picture', { thumb: '40x40' }) : null;
const userProfileLink = user.username ? `/profile/${user.username}` : '#';
const mediaTitle = media.title || 'Неизвестное произведение';
const mediaLink = media.path ? `/media/${media.path}` : '#';
// Calculate average rating for this specific review based on media characteristics
const characteristics = media.characteristics;
const ratings = review.ratings; // Expecting { [key]: number }
const applicableRatings = Object.entries(characteristics).reduce((acc, [key, label]) => {
// Check if the rating exists for this characteristic key and is a valid number
if (ratings && typeof ratings[key] === 'number' && ratings[key] >= 1 && ratings[key] <= 10) {
acc.push(ratings[key]);
}
return acc;
}, []);
const totalApplicableRating = applicableRatings.reduce((sum, value) => sum + value, 0);
const averageReviewRating = applicableRatings.length > 0
? (totalApplicableRating / applicableRatings.length).toFixed(1)
: 'N/A'; // Show N/A if no applicable ratings
return (
<div key={review.id} className="flex items-center bg-campfire-dark rounded-md p-3 mr-6 shadow-sm">
{/* User Avatar */}
{avatarUrl ? (
<img
src={avatarUrl}
alt={`${username}'s avatar`}
className="w-8 h-8 rounded-full object-cover mr-3 border border-campfire-ash/30"
/>
) : (
<FaUserCircle className="w-8 h-8 text-campfire-ash mr-3" />
)}
{/* Review Info */}
<div className="flex flex-col">
<div className="flex items-center text-sm">
{/* Link to user profile */}
<Link to={userProfileLink} className="font-semibold text-campfire-light hover:underline mr-1">
{username}
</Link>
<span className="text-campfire-ash mr-1">рецензирует</span>
{/* Link to media page */}
<Link to={mediaLink} className="font-semibold text-campfire-amber hover:underline">
{mediaTitle}
</Link>
</div>
{/* Display overall average for this review */}
<div className="flex items-center text-campfire-ash text-xs mt-1">
Оценка:
<span className="text-campfire-amber ml-1 mr-1">
{averageReviewRating} / 10
</span>
{averageReviewRating !== 'N/A' && <FaFire className="text-campfire-amber" size={12} />}
</div>
{/* Status */}
{review.status && statusLabels[review.status] && (
<div className="mt-1 text-xs text-campfire-ash">
Статус: <span className="font-medium text-campfire-light">{statusLabels[review.status]}</span>
</div>
)}
</div>
</div>
);
})}
</Marquee>
</div>
);
};
export default LatestReviewsMarquee;

View File

@ -0,0 +1,151 @@
import React, { useState, useEffect } from 'react';
import { pb } from '../../services/pocketbaseService'; // Import pb for create/update
const AchievementForm = ({ onSuccess, onCancel, achievementToEdit }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
xp_reward: 0,
// Add other fields like icon if you have them
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (achievementToEdit) {
setFormData({
title: achievementToEdit.title,
description: achievementToEdit.description,
xp_reward: achievementToEdit.xp_reward,
// Load other fields here
});
} else {
// Reset form if creating a new achievement
setFormData({
title: '',
description: '',
xp_reward: 0,
});
}
}, [achievementToEdit]);
const handleChange = (e) => {
const { name, value, type } = e.target;
setFormData({
...formData,
[name]: type === 'number' ? parseInt(value, 10) || 0 : value, // Parse number input
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
let record;
if (achievementToEdit) {
// Update existing achievement
console.log('PocketBase: Updating achievement:', achievementToEdit.id, formData);
record = await pb.collection('achievements').update(achievementToEdit.id, formData);
console.log('PocketBase: Achievement updated:', record);
} else {
// Create new achievement
console.log('PocketBase: Creating achievement:', formData);
record = await pb.collection('achievements').create(formData);
console.log('PocketBase: Achievement created:', record);
}
onSuccess(); // Call success callback
} catch (err) {
console.error('Error saving achievement:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="p-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-3 rounded-lg mb-4 text-sm">
{error}
</div>
)}
<div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-campfire-ash mb-1">Название</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
required
className="w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber"
/>
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-campfire-ash mb-1">Описание</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
required
rows="3"
className="w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber"
></textarea>
</div>
<div className="mb-4">
<label htmlFor="xp_reward" className="block text-sm font-medium text-campfire-ash mb-1">Награда XP</label>
<input
type="number"
id="xp_reward"
name="xp_reward"
value={formData.xp_reward}
onChange={handleChange}
required
min="0"
className="w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber"
/>
</div>
{/* Add file input for icon if needed */}
{/*
<div className="mb-4">
<label htmlFor="icon" className="block text-sm font-medium text-campfire-ash mb-1">Иконка</label>
<input
type="file"
id="icon"
name="icon"
onChange={handleFileChange} // You'll need a separate handler for files
className="w-full text-campfire-light text-sm file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-campfire-amber file:text-campfire-dark hover:file:bg-campfire-light"
/>
</div>
*/}
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-campfire-charcoal text-campfire-light rounded-md hover:bg-campfire-ash/30 transition-colors"
disabled={loading}
>
Отмена
</button>
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? 'Сохранение...' : achievementToEdit ? 'Сохранить изменения' : 'Создать достижение'}
</button>
</div>
</form>
);
};
export default AchievementForm;

View File

@ -0,0 +1,46 @@
import React, { useEffect } from 'react';
import { Outlet, useLocation, Navigate } from 'react-router-dom';
import AdminSidebar from './AdminSidebar';
import { useAuth } from '../../contexts/AuthContext';
import { canManageSystem } from '../../utils/permissions';
const AdminLayout = () => {
const { user, isInitialized } = useAuth();
const location = useLocation();
useEffect(() => {
console.log('AdminLayout - Location changed:', location.pathname);
}, [location]);
console.log('AdminLayout - Current location:', location.pathname);
console.log('AdminLayout - User:', user);
if (!isInitialized) {
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (!canManageSystem(user)) {
console.log('AdminLayout - User does not have system management rights, redirecting to home');
return <Navigate to="/" replace />;
}
console.log('AdminLayout - Rendering layout with Outlet');
return (
<div className="min-h-screen bg-campfire-dark pt-16">
<div className="flex">
<AdminSidebar />
<main className="flex-1 p-8">
<div className="bg-campfire-darker rounded-lg p-6 shadow-lg">
<Outlet />
</div>
</main>
</div>
</div>
);
};
export default AdminLayout;

View File

@ -0,0 +1,108 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { FaUsers, FaFilm, FaTrophy, FaHeadset, FaLightbulb, FaHome } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import {
canManageMedia,
canManageUsers,
canManageSeasons,
canManageAchievements,
canManageSuggestions,
canManageSupport
} from '../../utils/permissions';
const AdminSidebar = () => {
const location = useLocation();
const { user } = useAuth();
console.log('AdminSidebar - Current location:', location.pathname);
const menuItems = [
{
title: 'Главная',
path: '/admin',
icon: <FaHome />,
show: true // Всегда показываем главную
},
{
title: 'Медиа',
path: '/admin/media',
icon: <FaFilm />,
show: canManageMedia(user)
},
{
title: 'Пользователи',
path: '/admin/users',
icon: <FaUsers />,
show: canManageUsers(user)
},
{
title: 'Сезоны',
path: '/admin/seasons',
icon: <FaFilm />,
show: canManageSeasons(user)
},
{
title: 'Достижения',
path: '/admin/achievements',
icon: <FaTrophy />,
show: canManageAchievements(user)
},
{
title: 'Поддержка',
path: '/admin/support',
icon: <FaHeadset />,
show: canManageSupport(user)
},
{
title: 'Предложения',
path: '/admin/suggestions',
icon: <FaLightbulb />,
show: canManageSuggestions(user)
}
];
return (
<div className="w-64 bg-campfire-darker min-h-screen p-4">
<nav className="space-y-2">
{menuItems.map((item) => {
if (!item.show) return null;
const isActive = location.pathname === item.path ||
(item.path !== '/admin' && location.pathname.startsWith(item.path));
console.log(`AdminSidebar - Checking item ${item.path}:`, {
pathname: location.pathname,
itemPath: item.path,
isActive
});
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center space-x-2 px-4 py-2 rounded-md transition-colors ${
isActive
? 'bg-campfire-amber/20 text-campfire-amber'
: 'text-campfire-light hover:bg-campfire-ash/20'
}`
}
onClick={(e) => {
console.log(`AdminSidebar - Clicked on ${item.path}`);
e.preventDefault();
window.history.pushState({}, '', item.path);
window.dispatchEvent(new PopStateEvent('popstate'));
}}
>
{item.icon}
<span>{item.title}</span>
</NavLink>
);
})}
</nav>
</div>
);
};
export default AdminSidebar;

View File

@ -0,0 +1,713 @@
import React, { useState, useEffect, useMemo } from 'react'; // Import useMemo
import { createMedia, updateMedia, validateMediaData, formatMediaData, getFileUrl, mediaTypes } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
import { FaUpload, FaTimesCircle } from 'react-icons/fa'; // Import icons
import { v4 as uuidv4 } from 'uuid'; // Import uuid for unique IDs
// Define characteristic presets
const characteristicPresets = {
movie: [
{ key: 'story', label: 'Сюжет' },
{ key: 'acting', label: 'Актерская игра' },
{ key: 'visuals', label: 'Визуал' },
{ key: 'sound', label: 'Звук' },
{ key: 'atmosphere', label: 'Атмосфера' },
{ key: 'pacing', label: 'Темп' },
],
tv: [
{ key: 'story', label: 'Сюжет' },
{ key: 'acting', label: 'Актерская игра' },
{ key: 'visuals', label: 'Визуал' },
{ key: 'sound', label: 'Звук' },
{ key: 'characters', label: 'Персонажи' },
{ key: 'pacing', label: 'Темп' },
],
game: [
{ key: 'updates', label: 'Сопровождение' },
{ key: 'story', label: 'Сюжет' },
{ key: 'gameplay', label: 'Геймплей' },
{ key: 'visual', label: 'Визуал' },
{ key: 'community', label: 'Сообщество' },
{ key: 'atmosphere', label: 'Атмосфера' },
{ key: 'soundtrack', label: 'Саундтрек' },
{ key: 'openworld', label: 'Открытый мир' },
],
anime: [
{ key: 'story', label: 'Сюжет' },
{ key: 'characters', label: 'Персонажи' },
{ key: 'visuals', label: 'Визуал' },
{ key: 'sound', label: 'Звук' },
{ key: 'atmosphere', label: 'Атмосфера' },
{ key: 'pacing', label: 'Темп' },
],
// Add more types as needed
};
// Define progress type options
const progressTypeOptions = [
{ value: 'hours', label: 'Часы (для игр)' },
{ value: 'watched', label: 'Просмотрено (для фильмов/сериалов)' },
{ value: 'completed', label: 'Пройдено (для сюжетных игр)' },
];
function MediaForm({ media, onSuccess }) {
const [title, setTitle] = useState('');
const [path, setPath] = useState('');
const [type, setType] = useState('');
const [overview, setOverview] = useState('');
const [poster, setPoster] = useState(null);
const [backdrop, setBackdrop] = useState(null);
// Change characteristics state to an array of objects { id, key, label }
const [characteristics, setCharacteristics] = useState([]);
const [isPublished, setIsPublished] = useState(false);
const [isPopular, setIsPopular] = useState(false); // State for is_popular
const [progressType, setProgressType] = useState('completed'); // State for progress_type
const [releaseDate, setReleaseDate] = useState(''); // State for release_date
const [posterPreview, setPosterPreview] = useState(null);
const [backdropPreview, setBackdropPreview] = useState(null);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState([]);
const { user } = useAuth(); // Get current user
const isEditing = !!media; // Determine if we are editing or creating
useEffect(() => {
if (media) {
// Populate form for editing
setTitle(media.title || '');
setPath(media.path || '');
setType(media.type || '');
setOverview(media.overview || media.description || ''); // Use overview or description
// Convert characteristics object from PocketBase to array of { id, key, label }
let initialCharacteristicsArray = [];
try {
// Check if characteristics is already an object or needs parsing
const rawCharacteristics = typeof media.characteristics === 'object' && media.characteristics !== null
? media.characteristics // It's already an object
: (typeof media.characteristics === 'string' && media.characteristics ? JSON.parse(media.characteristics) : {}); // Parse if it's a string
// Convert the object { key: label } to an array of { id, key, label }
initialCharacteristicsArray = Object.entries(rawCharacteristics).map(([key, label]) => ({
id: uuidv4(), // Generate a unique ID for React key
key: key,
label: label
}));
} catch (e) {
console.error("Failed to parse characteristics JSON:", e);
initialCharacteristicsArray = []; // Default to empty array on error
}
setCharacteristics(initialCharacteristicsArray);
setIsPublished(media.is_published ?? false); // Use ?? for null/undefined check
setIsPopular(media.is_popular ?? false); // Populate is_popular
setProgressType(media.progress_type || 'completed'); // Populate progress_type, default to 'completed' if null/empty
// Populate release_date, format to YYYY-MM-DD for input type="date"
setReleaseDate(media.release_date ? new Date(media.release_date).toISOString().split('T')[0] : '');
// Set initial previews for existing files
setPosterPreview(getFileUrl(media, 'poster'));
setBackdropPreview(getFileUrl(media, 'backdrop'));
} else {
// Reset form for creation
setTitle('');
setPath('');
setType('');
setOverview('');
setPoster(null);
setBackdrop(null);
setCharacteristics([]); // Reset to empty array
setIsPublished(false);
setIsPopular(false); // Reset is_popular
setProgressType('completed'); // Default progressType to 'completed' for new media
setReleaseDate(''); // Reset release_date
setPosterPreview(null);
setBackdropPreview(null);
}
setErrors([]); // Clear errors on media change
}, [media]);
// Update path automatically when title changes, if creating
useEffect(() => {
if (!isEditing && title) {
// Simple slugification: lowercase, replace spaces with hyphens, remove non-alphanumeric
const slug = title
.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^a-z0-9-]/g, '') // Remove non-alphanumeric except hyphens
.replace(/--+/g, '-') // Replace multiple hyphens with single
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
setPath(slug);
} else if (!isEditing && !title) {
setPath(''); // Clear path if title is empty when creating
}
}, [title, isEditing]);
// Handle change for characteristic label (now takes item id and new label)
const handleCharacteristicLabelChange = (id, newLabel) => {
setCharacteristics(prev =>
prev.map(char =>
char.id === id ? { ...char, label: newLabel } : char
)
);
};
// Handle change for characteristic key (now takes item id and new key)
const handleCharacteristicKeyChange = (id, newKey) => {
// Basic validation: key cannot be empty
if (!newKey.trim()) {
setErrors(prev => [...prev, 'Ключ характеристики не может быть пустым.']);
return;
}
// Check for duplicate keys (excluding the item being edited)
const isDuplicate = characteristics.some(char =>
char.id !== id && char.key === newKey.trim()
);
if (isDuplicate) {
setErrors(prev => [...prev, `Ключ характеристики "${newKey.trim()}" уже существует.`]);
return;
}
setCharacteristics(prev =>
prev.map(char =>
char.id === id ? { ...char, key: newKey.trim() } : char
)
);
setErrors(prev => prev.filter(err => !err.startsWith('Ключ характеристики'))); // Clear key-related errors on successful change
};
const handleAddCharacteristic = () => {
// Check if max characteristics reached (8)
if (characteristics.length >= 8) {
setErrors(prev => [...prev, 'Максимальное количество характеристик - 8.']);
return;
}
// Add a new characteristic object with a unique ID and default values
const newChar = {
id: uuidv4(),
key: `new_char_${characteristics.length + 1}`, // Default key
label: '' // Empty label
};
setCharacteristics(prev => [...prev, newChar]);
setErrors(prev => prev.filter(err => !err.startsWith('Максимальное количество характеристик'))); // Clear max count error
};
const handleRemoveCharacteristic = (idToRemove) => {
// Check if minimum characteristics reached (6)
if (characteristics.length <= 6) {
setErrors(prev => [...prev, 'Минимальное количество характеристик - 6.']);
return;
}
setCharacteristics(prev => prev.filter(char => char.id !== idToRemove));
setErrors(prev => prev.filter(err => !err.startsWith('Минимальное количество характеристик'))); // Clear min count error
};
// Determine if the current type has a preset
const hasPreset = type && characteristicPresets[type];
// Get the preset characteristics for the current type (memoized)
const presetChars = useMemo(() => {
if (hasPreset) {
// Convert preset array to array of { id, key, label }
return characteristicPresets[type].map(char => ({
id: uuidv4(), // Generate new IDs for preset application
key: char.key,
label: char.label
}));
}
return [];
}, [type, hasPreset]); // Recompute if type or hasPreset changes
// Function to apply a preset
const applyPreset = () => {
if (hasPreset) {
setCharacteristics(presetChars); // Set the state with the memoized preset array
setErrors(prev => prev.filter(err => !err.includes('характеристик'))); // Clear characteristic count errors
}
};
const handleFileChange = (e, field) => {
const file = e.target.files[0];
if (file) {
if (field === 'poster') {
setPoster(file);
setPosterPreview(URL.createObjectURL(file));
} else if (field === 'backdrop') {
setBackdrop(file);
setBackdropPreview(URL.createObjectURL(file));
}
}
};
const handleRemoveFile = (field) => {
if (field === 'poster') {
setPoster(null);
setPosterPreview(null);
} else if (field === 'backdrop') {
setBackdrop(null);
setBackdropPreview(null);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setErrors([]);
const formData = new FormData();
formData.append('title', title);
formData.append('path', path);
formData.append('type', type);
formData.append('overview', overview); // Use overview field
formData.append('is_published', isPublished);
formData.append('is_popular', isPopular); // Include is_popular
formData.append('progress_type', progressType); // Include progress_type
// Append release_date if it exists
if (releaseDate) {
formData.append('release_date', releaseDate);
}
// Append files if they are new File objects
if (poster instanceof File) {
formData.append('poster', poster);
}
if (backdrop instanceof File) {
formData.append('backdrop', backdrop);
}
// Handle file deletion by setting the field to null in FormData
// This is necessary if the user removed a previously existing file
if (isEditing) {
// If editing and posterPreview is null but media.poster existed, set poster to null
if (!posterPreview && media.poster) {
formData.append('poster', ''); // PocketBase expects empty string or null for deletion
}
// If editing and backdropPreview is null but media.backdrop existed, set backdrop to null
if (!backdropPreview && media.backdrop) {
formData.append('backdrop', ''); // PocketBase expects empty string or null for deletion
}
}
// Convert characteristics array [{ id, key, label }] back to object { key: label } for PocketBase
const characteristicsObject = characteristics.reduce((acc, char) => {
// Only include if key is not empty
if (char.key.trim()) {
acc[char.key.trim()] = char.label;
}
return acc;
}, {});
// Append characteristics as a JSON string
formData.append('characteristics', JSON.stringify(characteristicsObject));
// Add created_by user ID if creating
if (!isEditing && user) {
formData.append('created_by', user.id);
}
// Validate data before submitting
// Pass the characteristics object for validation
const validationErrors = validateMediaData(formData, characteristicsObject);
if (validationErrors.length > 0) {
setErrors(validationErrors);
setLoading(false);
return;
}
// Format data (less critical with FormData, but can be used for final checks)
const dataToSubmit = formatMediaData(formData);
try {
if (isEditing) {
await updateMedia(media.id, dataToSubmit);
console.log('Media updated successfully');
} else {
await createMedia(dataToSubmit);
console.log('Media created successfully');
}
onSuccess(); // Close modal and refresh list
} catch (err) {
console.error('Error submitting media form:', err);
if (err.response && err.response.data) {
console.error('PocketBase Response Data:', err.response.data);
// Attempt to extract specific error messages from PocketBase response
const apiErrors = [];
for (const field in err.response.data) {
if (err.response.data[field].message) {
apiErrors.push(`${field}: ${err.response.data[field].message}`);
}
}
if (apiErrors.length > 0) {
setErrors(apiErrors);
} else {
setErrors(['Произошла ошибка при сохранении контента.']);
}
} else {
setErrors(['Произошла ошибка при сохранении контента.']);
}
} finally {
setLoading(false);
}
};
return (
<div className="p-6 bg-campfire-charcoal rounded-lg shadow-md text-campfire-light max-h-[80vh] overflow-y-auto custom-scrollbar">
<h2 className="text-2xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4">
{isEditing ? 'Редактировать контент' : 'Добавить новый контент'}
</h2>
{errors.length > 0 && (
<div className="bg-status-error/20 text-status-error p-4 rounded-md mb-6 border border-status-error/30">
<ul className="list-disc list-inside">
{errors.map((err, index) => (
<li key={index}>{err}</li>
))}
</ul>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-campfire-light mb-1">
Название <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
required
/>
</div>
<div>
<label htmlFor="path" className="block text-sm font-medium text-campfire-light mb-1">
Путь (URL Slug) <span className="text-red-500">*</span>
</label>
<input
type="text"
id="path"
value={path}
onChange={(e) => setPath(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
required
/>
<p className="mt-1 text-sm text-campfire-ash">
Используется в URL (например, `/media/ваш-путь`). Только латинские буквы, цифры и дефисы.
</p>
</div>
<div>
<label htmlFor="type" className="block text-sm font-medium text-campfire-light mb-1">
Тип <span className="text-red-500">*</span>
</label>
<select
id="type"
value={type}
onChange={(e) => setType(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
required
>
<option value="">Выберите тип</option>
{Object.entries(mediaTypes).map(([key, value]) => (
<option key={key} value={key}>{value.label}</option>
))}
</select>
</div>
{/* Progress Type Selection */}
<div>
<label htmlFor="progress_type" className="block text-sm font-medium text-campfire-light mb-1">
Тип отслеживания прогресса <span className="text-red-500">*</span>
</label>
<select
id="progress_type"
value={progressType}
onChange={(e) => setProgressType(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
required
>
{/* Removed default empty option, 'completed' is now the default state */}
{progressTypeOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<p className="mt-1 text-sm text-campfire-ash">
Определяет, как пользователи будут отмечать прогресс (часы, просмотрено/нет, пройдено/нет).
</p>
</div>
{/* Release Date */}
<div>
<label htmlFor="release_date" className="block text-sm font-medium text-campfire-light mb-1">
Дата выхода
</label>
<input
type="date"
id="release_date"
value={releaseDate}
onChange={(e) => setReleaseDate(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
/>
</div>
<div>
<label htmlFor="overview" className="block text-sm font-medium text-campfire-light mb-1">
Описание / Обзор
</label>
<textarea
id="overview"
rows="4"
value={overview}
onChange={(e) => setOverview(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
></textarea>
</div>
{/* File Uploads */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Poster Upload */}
<div>
<label className="block text-sm font-medium text-campfire-light mb-1">
Постер
</label>
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-campfire-ash/30 border-dashed rounded-md relative">
{posterPreview ? (
<>
<img src={posterPreview} alt="Poster Preview" className="max-h-40 object-contain" />
<button
type="button"
onClick={() => handleRemoveFile('poster')}
className="absolute top-2 right-2 text-status-error hover:text-red-700 transition-colors"
aria-label="Remove poster"
>
<FaTimesCircle size={20} />
</button>
</>
) : (
<div className="space-y-1 text-center">
<FaUpload className="mx-auto h-12 w-12 text-campfire-ash" />
<div className="flex text-sm text-campfire-ash">
<label
htmlFor="poster-upload"
className="relative cursor-pointer bg-campfire-charcoal rounded-md font-medium text-campfire-amber hover:text-campfire-ember focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-campfire-amber"
>
<span>Загрузить файл</span>
<input
id="poster-upload"
name="poster"
type="file"
className="sr-only"
onChange={(e) => handleFileChange(e, 'poster')}
accept="image/*"
/>
</label>
<p className="pl-1">или перетащите сюда</p>
</div>
<p className="text-xs text-campfire-ash">PNG, JPG, GIF до 10MB</p>
</div>
)}
</div>
</div>
{/* Backdrop Upload */}
<div>
<label className="block text-sm font-medium text-campfire-light mb-1">
Фон (Backdrop)
</label>
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-campfire-ash/30 border-dashed rounded-md relative">
{backdropPreview ? (
<>
<img src={backdropPreview} alt="Backdrop Preview" className="max-h-40 object-contain" />
<button
type="button"
onClick={() => handleRemoveFile('backdrop')}
className="absolute top-2 right-2 text-status-error hover:text-red-700 transition-colors"
aria-label="Remove backdrop"
>
<FaTimesCircle size={20} />
</button>
</>
) : (
<div className="space-y-1 text-center">
<FaUpload className="mx-auto h-12 w-12 text-campfire-ash" />
<div className="flex text-sm text-campfire-ash">
<label
htmlFor="backdrop-upload"
className="relative cursor-pointer bg-campfire-charcoal rounded-md font-medium text-campfire-amber hover:text-campfire-ember focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-campfire-amber"
>
<span>Загрузить файл</span>
<input
id="backdrop-upload"
name="backdrop"
type="file"
className="sr-only"
onChange={(e) => handleFileChange(e, 'backdrop')}
accept="image/*"
/>
</label>
<p className="pl-1">или перетащите сюда</p>
</div>
<p className="text-xs text-campfire-ash">PNG, JPG, GIF до 10MB</p>
</div>
)}
</div>
</div>
</div>
{/* Characteristics */}
<div className="border-t border-campfire-ash/20 pt-6 mt-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-campfire-light">
Характеристики <span className="text-red-500">*</span>
</h3>
{hasPreset && (
<button
type="button"
onClick={applyPreset}
className="btn-secondary text-sm"
>
Применить пресет "{mediaTypes[type]?.label || type}"
</button>
)}
</div>
<p className="mb-4 text-sm text-campfire-ash">
Определите характеристики, по которым пользователи будут оценивать контент (минимум 6, максимум 8).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Use grid for better layout */}
{/* Map over the characteristics array */}
{characteristics.map((char) => (
<div key={char.id} className="flex items-center space-x-2 bg-campfire-dark rounded-md p-3 border border-campfire-ash/30"> {/* Styled container */}
<div className="flex-1 grid grid-cols-2 gap-3"> {/* Inner grid for key/label */}
{/* Key Input */}
<div>
<label htmlFor={`char-key-${char.id}`} className="block text-xs font-medium text-campfire-ash mb-1">
Ключ (для системы)
</label>
<input
type="text"
id={`char-key-${char.id}`}
value={char.key}
onChange={(e) => handleCharacteristicKeyChange(char.id, e.target.value)}
className="input w-full px-2 py-1 bg-campfire-charcoal text-campfire-light border border-campfire-ash/20 rounded-md text-sm focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="e.g., story"
required
/>
</div>
{/* Label Input */}
<div>
<label htmlFor={`char-label-${char.id}`} className="block text-xs font-medium text-campfire-ash mb-1">
Название (для пользователей)
</label>
<input
type="text"
id={`char-label-${char.id}`}
value={char.label}
onChange={(e) => handleCharacteristicLabelChange(char.id, e.target.value)}
className="input w-full px-2 py-1 bg-campfire-charcoal text-campfire-light border border-campfire-ash/20 rounded-md text-sm focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="e.g., Сюжет"
required
/>
</div>
</div>
{characteristics.length > 6 && ( // Only show remove if more than 6
<button
type="button"
onClick={() => handleRemoveCharacteristic(char.id)}
className="text-status-error hover:text-red-700 transition-colors p-1"
aria-label={`Remove characteristic ${char.label}`}
>
<FaTimesCircle size={18} /> {/* Slightly smaller icon */}
</button>
)}
</div>
))}
</div>
{characteristics.length < 8 && ( // Only show add if less than 8
<button
type="button"
onClick={handleAddCharacteristic}
className="btn-secondary mt-4 text-sm"
>
Добавить характеристику
</button>
)}
</div>
{/* Status Toggles */}
<div className="border-t border-campfire-ash/20 pt-6 mt-6">
<h3 className="text-xl font-bold text-campfire-light mb-4">Статус</h3>
<div className="flex items-center space-x-6">
{/* Is Published */}
<div className="flex items-center">
<input
type="checkbox"
id="is_published"
checked={isPublished}
onChange={(e) => setIsPublished(e.target.checked)}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2 cursor-pointer"
/>
<label htmlFor="is_published" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer">
Опубликовано
</label>
</div>
{/* Is Popular */}
<div className="flex items-center">
<input
type="checkbox"
id="is_popular"
checked={isPopular}
onChange={(e) => setIsPopular(e.target.checked)}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2 cursor-pointer"
/>
<label htmlFor="is_popular" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer">
Популярное
</label>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={loading}
className={`btn-primary ${loading ? 'opacity-50 cursor-not-allowed' : ''} transition-colors duration-200`}
>
{loading ? (isEditing ? 'Сохранение...' : 'Создание...') : (isEditing ? 'Сохранить изменения' : 'Создать контент')}
</button>
</div>
</form>
</div>
);
}
export default MediaForm;

View File

@ -0,0 +1,29 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { canManageSystem } from '../../utils/permissions';
const AdminRoute = () => {
const { user, isInitialized } = useAuth();
const location = useLocation();
console.log('AdminRoute - Current location:', location.pathname);
console.log('AdminRoute - User:', user);
if (!isInitialized) {
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (!canManageSystem(user)) {
console.log('AdminRoute - User does not have system management rights, redirecting to home');
return <Navigate to="/" replace />;
}
console.log('AdminRoute - User has system management rights, rendering child routes');
return <Outlet />;
};
export default AdminRoute;

View File

@ -0,0 +1,26 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // Adjust path if necessary
const AuthRoute = () => { // Renamed component from ProtectedRoute to AuthRoute
const { user, isInitialized } = useAuth();
// Wait for auth state to be initialized
if (!isInitialized) {
// You might want a loading spinner here
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
// If user is not authenticated, redirect to login page
if (!user) {
return <Navigate to="/auth" replace />;
}
// If user is authenticated, render the child routes
return <Outlet />;
};
export default AuthRoute; // Exporting AuthRoute

View File

@ -0,0 +1,31 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // Adjust path if necessary
const GuestRoute = () => {
const { user, isInitialized } = useAuth();
// Wait for auth state to be initialized
if (!isInitialized) {
// You might want a loading spinner here
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
// If user is authenticated, redirect them away from guest-only routes
// Redirect to profile page or home page
if (user) {
// Assuming userProfile contains username, otherwise redirect to a default authenticated page like '/'
// If userProfile is not immediately available, you might redirect to a loading page or '/'
// For now, let's redirect to the home page or profile if username is available
// A simple redirect to '/' is safer if userProfile isn't guaranteed here
return <Navigate to="/" replace />;
}
// If user is not authenticated, render the child routes
return <Outlet />;
};
export default GuestRoute;

View File

@ -0,0 +1,49 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { FaTimes } from 'react-icons/fa'; // Assuming you have react-icons installed
// Added size prop to control modal width
const Modal = ({ isOpen, onClose, title, children, size = 'lg' }) => {
if (!isOpen) return null;
// Determine max-width class based on size prop
let maxWidthClass = 'max-w-lg'; // Default size
if (size === 'md') {
maxWidthClass = 'max-w-md';
} else if (size === 'xl') {
maxWidthClass = 'max-w-xl';
} else if (size === '2xl') {
maxWidthClass = 'max-w-2xl';
} else if (size === 'full') {
maxWidthClass = 'max-w-full'; // Use with caution, might need padding adjustments
}
// Use createPortal to render the modal outside the main app div
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4">
{/* Use maxWidthClass here */}
<div className={`bg-campfire-charcoal rounded-lg shadow-xl w-full ${maxWidthClass} max-h-[90vh] overflow-y-auto relative`}>
{/* Modal Header */}
<div className="flex justify-between items-center p-4 border-b border-campfire-ash/20">
<h3 className="text-xl font-semibold text-campfire-light">{title}</h3>
<button
onClick={onClose}
className="text-campfire-ash hover:text-campfire-light transition-colors"
aria-label="Close modal"
>
<FaTimes size={20} />
</button>
</div>
{/* Modal Body */}
<div className="p-4">
{children}
</div>
</div>
</div>,
document.body // Render into the body
);
};
export default Modal;

View File

@ -0,0 +1,53 @@
import React from 'react';
import { Link } from 'react-router-dom';
import TiltedCard from '../reactbits/Components/TiltedCard/TiltedCard';
import { getFileUrl } from '../../services/pocketbaseService';
const FeaturedMedia = ({ media }) => {
if (!media || media.length === 0) {
return null;
}
return (
<section className="py-16">
<div className="container-custom">
<h2 className="text-3xl font-bold text-campfire-light mb-8">Популярное</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{media.map((item) => (
<Link
key={item.id}
to={`/media/${item.path}`}
className="block group"
>
<TiltedCard
imageSrc={item.poster ? getFileUrl(item, 'poster', { thumb: '300x450' }) : 'https://via.placeholder.com/300x450'}
altText={item.title}
captionText={item.title}
containerHeight="450px"
containerWidth="100%"
imageHeight="450px"
imageWidth="300px"
scaleOnHover={1.05}
rotateAmplitude={10}
showMobileWarning={false}
showTooltip={true}
displayOverlayContent={true}
overlayContent={
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent p-4 flex flex-col justify-end">
<h3 className="text-xl font-bold text-white mb-2">{item.title}</h3>
<div className="flex items-center gap-2 text-campfire-amber">
<span className="text-lg font-medium">{item.rating}</span>
<span className="text-sm">/ 10</span>
</div>
</div>
}
/>
</Link>
))}
</div>
</div>
</section>
);
};
export default FeaturedMedia;

View File

@ -0,0 +1,44 @@
import React from 'react';
import GridMotion from '../reactbits/Backgrounds/GridMotion/GridMotion';
import StatsSection from './StatsSection';
const GridSection = ({ posters = [], stats = {} }) => {
// Функция для перемешивания массива
const shuffleArray = (array) => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
// Перемешиваем постеры
const shuffledPosters = shuffleArray(posters);
return (
<div className="relative -mt-16">
{/* Stats Section with Background */}
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="w-full max-w-4xl mx-auto px-4">
<div className="bg-campfire-charcoal/80 backdrop-blur-md rounded-lg p-6 border border-campfire-ash/20">
<StatsSection stats={stats} />
</div>
</div>
</div>
{/* Grid Motion Background */}
<div className="relative overflow-hidden">
{shuffledPosters.length > 0 && (
<GridMotion
items={shuffledPosters}
gradientColor="rgba(23, 23, 23, 0.8)"
className="absolute inset-0"
/>
)}
</div>
</div>
);
};
export default GridSection;

View File

@ -0,0 +1,20 @@
import React from 'react';
const HeroSection = () => {
return (
<section className="relative bg-campfire-dark py-20">
<div className="container-custom">
<div className="max-w-3xl mx-auto text-center">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-campfire-light mb-6">
Campfire мнеие
</h1>
</div>
</div>
{/* Декоративный градиент */}
<div className="absolute inset-0 bg-gradient-to-b from-campfire-charcoal/50 to-transparent pointer-events-none" />
</section>
);
};
export default HeroSection;

View File

@ -0,0 +1,40 @@
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
const HomeContent = () => {
return (
<div className="min-h-screen bg-campfire-dark">
{/* Hero Section с наложением на Grid */}
<div className="relative z-10">
<div className="container-custom mx-auto px-4 pt-32 pb-16">
<div className="max-w-3xl mx-auto text-center">
<h1 className="text-4xl md:text-5xl font-bold text-campfire-light mb-6">
Составляй рецензии на
</h1>
<div className="bg-campfire-charcoal/80 backdrop-blur-md rounded-lg p-6 border border-campfire-ash/20">
<SearchBar />
</div>
</div>
</div>
</div>
{/* Grid начинается с верха страницы */}
<div className="relative -mt-32">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 md:gap-10 container-custom mx-auto px-4">
{mediaItems.map((item) => (
<div className="transform hover:scale-105 transition-transform duration-300">
<TiltedCard
key={item.id}
imageSrc={item.poster_path}
captionText={item.title}
rating={item.rating}
releaseDate={item.release_date}
/>
</div>
))}
</div>
</div>
</div>
);
};
export default HomeContent;

View File

@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react';
import CountUp from '../reactbits/TextAnimations/CountUp/CountUp';
import RotatingText from '../reactbits/TextAnimations/RotatingText/RotatingText';
import { getMediaCount, getReviewsCount } from '../../services/pocketbaseService';
const StatsSection = () => {
const [stats, setStats] = useState({
mediaCount: 0,
reviewsCount: 0
});
useEffect(() => {
const fetchStats = async () => {
try {
const [mediaCount, reviewsCount] = await Promise.all([
getMediaCount(),
getReviewsCount()
]);
const mediaCountNum = parseInt(mediaCount) || 0;
const reviewsCountNum = parseInt(reviewsCount) || 0;
setStats({
mediaCount: mediaCountNum,
reviewsCount: reviewsCountNum
});
} catch (error) {
console.error('Error fetching stats:', error);
setStats({ mediaCount: 0, reviewsCount: 0 });
}
};
fetchStats();
}, []);
return (
<section className="py-16">
<div className="container-custom">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-campfire-light mb-4">
<div className="flex items-center justify-center gap-2">
<span>Составляй рецензии на</span>
<div className="w-32 text-left">
<RotatingText
texts={[
"Фильмы",
"Сериалы",
"Игры",
"Аниме"
]}
className="inline-block text-campfire-amber drop-shadow-[0_0_8px_rgba(255,51,0,0.6)]"
rotationInterval={1500}
auto={true}
loop={true}
/>
</div>
</div>
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl mx-auto">
<div className="bg-campfire-darker p-6 rounded-lg shadow-lg text-center">
<h3 className="text-campfire-light text-lg mb-2">Медиа в каталоге</h3>
<CountUp
to={stats.mediaCount}
className="text-4xl font-bold text-campfire-amber"
duration={2}
start={0}
/>
</div>
<div className="bg-campfire-darker p-6 rounded-lg shadow-lg text-center">
<h3 className="text-campfire-light text-lg mb-2">Рецензий написано</h3>
<CountUp
to={stats.reviewsCount}
className="text-4xl font-bold text-campfire-amber"
duration={2}
start={0}
/>
</div>
</div>
</div>
</section>
);
};
export default StatsSection;

View File

@ -0,0 +1,94 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { FaTachometerAlt, FaFilm, FaUsers, FaHeadset, FaTrophy, FaLightbulb } from 'react-icons/fa';
const AdminSidebar = () => {
const location = useLocation();
const isActive = (path) => {
if (path === '/admin') {
return location.pathname === '/admin';
}
return location.pathname.startsWith(path);
};
return (
<div className="w-64 bg-campfire-charcoal border-r border-campfire-ash/20 h-screen fixed left-0 top-0 pt-16">
<nav className="p-4">
<NavLink
to="/admin/"
className={({ isActive }) =>
`flex items-center space-x-2 px-4 py-2 rounded-lg mb-2 transition-colors ${
isActive ? 'bg-campfire-amber/20 text-campfire-amber' : 'text-campfire-light hover:bg-campfire-ash/20'
}`
}
>
<FaTachometerAlt className="w-5 h-5" />
<span>Главная</span>
</NavLink>
<NavLink
to="/admin/media"
className={({ isActive }) =>
`flex items-center space-x-2 px-4 py-2 rounded-lg mb-2 transition-colors ${
isActive ? 'bg-campfire-amber/20 text-campfire-amber' : 'text-campfire-light hover:bg-campfire-ash/20'
}`
}
>
<FaFilm className="w-5 h-5" />
<span>Медиа</span>
</NavLink>
<NavLink
to="/admin/users"
className={({ isActive }) =>
`flex items-center space-x-2 px-4 py-2 rounded-lg mb-2 transition-colors ${
isActive ? 'bg-campfire-amber/20 text-campfire-amber' : 'text-campfire-light hover:bg-campfire-ash/20'
}`
}
>
<FaUsers className="w-5 h-5" />
<span>Пользователи</span>
</NavLink>
<NavLink
to="/admin/support"
className={({ isActive }) =>
`flex items-center space-x-2 px-4 py-2 rounded-lg mb-2 transition-colors ${
isActive ? 'bg-campfire-amber/20 text-campfire-amber' : 'text-campfire-light hover:bg-campfire-ash/20'
}`
}
>
<FaHeadset className="w-5 h-5" />
<span>Поддержка</span>
</NavLink>
<NavLink
to="/admin/achievements"
className={({ isActive }) =>
`flex items-center space-x-2 px-4 py-2 rounded-lg mb-2 transition-colors ${
isActive ? 'bg-campfire-amber/20 text-campfire-amber' : 'text-campfire-light hover:bg-campfire-ash/20'
}`
}
>
<FaTrophy className="w-5 h-5" />
<span>Достижения</span>
</NavLink>
<NavLink
to="/admin/suggestions"
className={({ isActive }) =>
`flex items-center space-x-2 px-4 py-2 rounded-lg mb-2 transition-colors ${
isActive ? 'bg-campfire-amber/20 text-campfire-amber' : 'text-campfire-light hover:bg-campfire-ash/20'
}`
}
>
<FaLightbulb className="w-5 h-5" />
<span>Предложения</span>
</NavLink>
</nav>
</div>
);
};
export default AdminSidebar;

View File

@ -0,0 +1,198 @@
import { useState, useEffect } from 'react';
import { FaDiscord, FaTelegramPlane, FaFire } from "react-icons/fa";
import {
RiOpenaiFill,
RiGeminiLine,
RiStackOverflowLine,
} from "react-icons/ri";
import { Link } from "react-router-dom";
function Footer() {
const currentYear = new Date().getFullYear();
// State for managing logo source
const [logoSrc, setLogoSrc] = useState('/logo.png');
const [showTextLogo, setShowTextLogo] = useState(false);
// Effect to check local logo availability on mount
useEffect(() => {
const img = new Image();
img.onload = () => setLogoSrc('/logo.png');
img.onerror = () => setLogoSrc('https://campfiregg.ru/logo.png');
img.src = '/logo.png';
}, []);
const handleExternalLogoError = () => {
setShowTextLogo(true);
};
return (
<footer className="bg-campfire-darker py-12 mt-20">
<div className="container-custom">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="col-span-1 md:col-span-1">
<Link to="/" className="flex items-center">
{showTextLogo ? (
<span className="text-campfire-primary text-xl font-bold">CampFire мнеие</span>
) : (
<img
src={logoSrc}
alt="CampFire мнеие"
className="h-8 object-contain"
onError={handleExternalLogoError}
/>
)}
</Link>
<p className="mt-4 text-campfire-light/60">
Делаем хорошо, но на отъебись.
</p>
<div className="flex mt-6 space-x-4">
<a
href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
<FaDiscord size={20} />
</a>
<a
href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
<FaTelegramPlane size={20} />
</a>
<a
href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
<FaFire size={20} />
</a>
</div>
</div>
{/* Navigation */}
<div className="col-span-1">
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Атлас</h3>
<ul className="space-y-2">
<li>
<Link
to="/"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Главная
</Link>
</li>
<li>
<Link
to="/catalog?type=movie"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Фильмы
</Link>
</li>
<li>
<Link
to="/catalog?type=tv"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Сериалы
</Link>
</li>
<li>
<Link
to="/catalog?type=game"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Игры
</Link>
</li>
<li>
<Link
to="/catalog?type=anime"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Аниме
</Link>
</li>
</ul>
</div>
{/* Legal */}
<div className="col-span-1">
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Правовая информация</h3>
<ul className="space-y-2">
<li>
<Link
to="/privacy-policy"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Политика конфиденциальности
</Link>
</li>
<li>
<Link
to="/terms-of-service"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Условия использования
</Link>
</li>
<li>
<Link
to="/user-agreement"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Пользовательское соглашение
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div className="col-span-1">
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Контакты</h3>
<ul className="space-y-2">
<li className="text-campfire-light/60">
<span>general@campfiregg.ru</span>
</li>
<li>
<Link
to="/support"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Связаться с нами
</Link>
</li>
<li>
<Link
to="/faq"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
FAQ
</Link>
</li>
</ul>
</div>
</div>
<div className="border-t border-campfire-dark mt-12 pt-8 text-center text-campfire-light/60">
<p>
&copy; {currentYear} CampFire мнеие. Никакие права не защищены.
</p>
<p className="mt-2 text-sm">
<a
href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors inline-flex items-center"
>
VibeCoded with
<RiStackOverflowLine className="ml-1" size={20} />
<RiGeminiLine className="ml-1" size={20} />
<RiOpenaiFill className="ml-1" size={20} />
</a>
</p>
</div>
</div>
</footer>
);
}
export default Footer;

View File

@ -0,0 +1,390 @@
import React, { useState, useRef, useEffect } from 'react';
import { Link, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useProfileActions } from '../../contexts/ProfileActionsContext';
import { getFileUrl } from '../../services/pocketbaseService';
import { FaUserCircle, FaSignOutAlt, FaEdit, FaBars, FaTimes, FaTachometerAlt, FaBook, FaTrophy, FaCog, FaHeadset, FaHome, FaUser } from 'react-icons/fa'; // Import FaBook, FaTrophy, FaCog, and FaHeadset
import { FiSearch } from 'react-icons/fi';
import SearchBar from '../ui/SearchBar';
import AlphaBadge from '../ui/AlphaBadge';
import localLogo from '/logo.png';
import { canManageSystem } from '../../utils/permissions';
import Dock from '../reactbits/Components/Dock/Dock';
import '../reactbits/Components/Dock/Dock.css';
import { AnimatePresence, motion } from 'framer-motion';
const Header = () => {
const { user, userProfile, signOut } = useAuth();
const profileActions = useProfileActions();
const navigate = useNavigate();
const location = useLocation();
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenu] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [logoSrc, setLogoSrc] = useState(localLogo);
const profileMenuRef = useRef(null);
const mobileMenuRef = useRef(null);
const searchContainerRef = useRef(null); // Ref for the search dropdown container
// Эффект для проверки доступности локального логотипа при монтировании
useEffect(() => {
const img = new Image();
img.onload = () => setLogoSrc(localLogo); // Если локальный загрузился, используем его
img.onerror = () => setLogoSrc('https://campfiregg.ru/logo.png'); // Если локальный не загрузился, пробуем внешний
img.src = localLogo;
}, []); // Запускаем только один раз при монтировании
const handleExternalLogoError = () => {
console.log("External logo failed to load.");
};
const toggleProfileMenu = () => {
setIsProfileMenuOpen(!isProfileMenuOpen);
setIsMobileMenu(false); // Close mobile menu if profile opens
setIsSearchOpen(false); // Close search if profile opens
};
const toggleMobileMenu = () => {
setIsMobileMenu(!isMobileMenuOpen);
setIsProfileMenuOpen(false); // Close profile menu if mobile opens
setIsSearchOpen(false); // Close search if mobile opens
};
const toggleSearch = () => {
setIsSearchOpen(!isSearchOpen);
setIsProfileMenuOpen(false); // Close other menus
setIsMobileMenu(false); // Close other menus
};
const closeMenus = () => {
setIsProfileMenuOpen(false);
setIsMobileMenu(false);
// Keep search open if it was opened via its own button, close only if clicking outside
};
const closeSearch = () => {
setIsSearchOpen(false);
};
// Close menus/search when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) {
setIsProfileMenuOpen(false);
}
if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target)) {
setIsMobileMenu(false);
}
// Close search only if clicking outside the search container itself
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
// Check if the click was NOT on the search toggle button
const searchButton = document.getElementById('search-toggle-button');
if (searchButton && searchButton !== event.target && !searchButton.contains(event.target)) {
setIsSearchOpen(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Handle Edit Profile click from Header
const handleEditProfileClick = () => {
if (userProfile) {
console.log('Header: Edit Profile clicked. Navigating to profile and triggering modal.');
navigate(`/profile/${userProfile.username}`);
if (profileActions?.triggerEditModal) {
profileActions.triggerEditModal();
}
closeMenus();
} else {
console.log('Header: Edit Profile clicked, but userProfile is not available.');
}
};
const canAccessAdmin = canManageSystem(user);
const mainItems = [
{
icon: <FaHome className="w-5 h-5 text-campfire-light" />,
label: 'Главная',
onClick: () => navigate('/'),
className: location.pathname === '/' ? 'bg-campfire-amber/20' : ''
},
{
icon: <FaBook className="w-5 h-5 text-campfire-light" />,
label: 'Каталог',
onClick: () => navigate('/catalog'),
className: location.pathname === '/catalog' ? 'bg-campfire-amber/20' : ''
},
{
icon: <FaTrophy className="w-5 h-5 text-campfire-light" />,
label: 'Рейтинги',
onClick: () => navigate('/rating'),
className: location.pathname === '/rating' ? 'bg-campfire-amber/20' : ''
},
{
icon: <FiSearch className="w-5 h-5 text-campfire-light" />,
label: 'Поиск',
onClick: toggleSearch,
className: isSearchOpen ? 'bg-campfire-amber/20' : ''
}
];
const profileItems = [
{
icon: (
<div className="flex items-center gap-2 px-2">
<img
src={userProfile?.profile_picture ? getFileUrl(userProfile, 'profile_picture') : 'https://pocketbase.campfiregg.ru/api/files/_pb_users_auth_/g520s25pzm0t6e1/photo_2025_05_17_22_26_21_g2bi9umsuu.jpg'}
alt={userProfile?.username || 'User'}
className="w-6 h-6 rounded-full object-cover border border-campfire-ash/30"
/>
<span className="text-[10px] text-campfire-light truncate max-w-[60px]">{userProfile?.username || 'Профиль'}</span>
</div>
),
onClick: () => navigate(userProfile ? `/profile/${userProfile.username}` : '/auth/login'),
className: location.pathname.startsWith('/profile') ? 'bg-campfire-amber/20' : '',
showLabel: false,
isRectangular: true
},
{
icon: <FaBars className="w-5 h-5 text-campfire-light" />,
label: 'Меню',
onClick: toggleProfileMenu,
className: isProfileMenuOpen ? 'bg-campfire-amber/20' : ''
}
];
return (
<>
<header className="fixed top-2 left-0 right-0 z-50">
<div className="bg-campfire-charcoal/80 backdrop-blur-md border-b border-campfire-ash/20 rounded-t-lg">
<div className="container-custom flex items-center justify-between h-16 px-4">
{/* Logo */}
<Link to="/" className="flex items-center space-x-2">
<img
src={logoSrc}
alt="CampFire мнение"
className="h-12"
onError={handleExternalLogoError}
/>
</Link>
{/* Main Dock Navigation */}
<div className="hidden md:flex flex-1 justify-center">
<div className="flex items-center gap-4">
<Dock
items={mainItems}
className="bg-campfire-charcoal/50 backdrop-blur-sm"
distance={120}
panelHeight={50}
baseItemSize={32}
dockHeight={30}
magnification={40}
spring={{ mass: 0.2, stiffness: 200, damping: 15 }}
/>
{/* Search Dock */}
<AnimatePresence>
{isSearchOpen && (
<motion.div
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: '300px' }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.2 }}
className="rounded-md overflow-hidden"
>
<div className="px-4 py-2">
<SearchBar onClose={closeSearch} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Profile Dock */}
<div className="hidden md:flex relative">
<Dock
items={profileItems}
className="bg-campfire-charcoal/50 backdrop-blur-sm"
distance={120}
panelHeight={50}
baseItemSize={32}
dockHeight={30}
magnification={40}
spring={{ mass: 0.2, stiffness: 200, damping: 15 }}
/>
{/* Profile Dropdown Menu */}
<AnimatePresence>
{isProfileMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute right-0 top-full mt-2 w-48 bg-campfire-charcoal rounded-md shadow-lg py-1 z-50 border border-campfire-ash/30"
>
<Link
to={`/profile/${userProfile?.username}`}
onClick={closeMenus}
className="block px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaUserCircle />
<span>Мой профиль</span>
</Link>
{canAccessAdmin && (
<Link
to="/admin"
onClick={closeMenus}
className="block px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaTachometerAlt />
<span>Админ панель</span>
</Link>
)}
{userProfile && (
<button
onClick={handleEditProfileClick}
className="block w-full text-left px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaEdit />
<span>Редактировать профиль</span>
</button>
)}
{user && (
<>
<Link
to="/support"
className="block px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaHeadset />
<span>Поддержка</span>
</Link>
<button
onClick={() => {
signOut();
closeMenus();
}}
className="block w-full text-left px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaSignOutAlt />
<span>Выйти</span>
</button>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Mobile Menu Button */}
<div className="md:hidden">
<button onClick={toggleMobileMenu} className="text-campfire-light focus:outline-none">
{isMobileMenuOpen ? <FaTimes size={24} /> : <FaBars size={24} />}
</button>
</div>
</div>
</div>
{/* Search Results Dropdown */}
<AnimatePresence>
{isSearchOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="bg-campfire-charcoal shadow-lg border-b border-campfire-ash/30 overflow-hidden"
>
<div className="container-custom mx-auto px-4 py-4">
{/* Здесь будет компонент с результатами поиска */}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Mobile Menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.nav
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
ref={mobileMenuRef}
className="md:hidden bg-campfire-charcoal border-t border-campfire-ash/20"
>
<div className="container-custom py-4 px-4">
<NavLink
to="/catalog"
onClick={closeMenus}
className={({ isActive }) =>
`block px-4 py-2 hover:bg-campfire-ash/20 transition-colors ${isActive ? 'text-campfire-amber' : 'text-campfire-light'} flex items-center space-x-2`
}
>
<FaBook size={20} />
<span>Каталог</span>
</NavLink>
<NavLink
to="/rating"
onClick={closeMenus}
className={({ isActive }) =>
`block px-4 py-2 hover:bg-campfire-ash/20 transition-colors ${isActive ? 'text-campfire-amber' : 'text-campfire-light'} flex items-center space-x-2`
}
>
<FaTrophy size={20} />
<span>Рейтинги</span>
</NavLink>
{user && (
<button
onClick={() => {
signOut();
closeMenus();
}}
className="block w-full text-left px-4 py-2 text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaSignOutAlt />
<span>Выйти</span>
</button>
)}
</div>
</motion.nav>
)}
</AnimatePresence>
</header>
{/* Мобильный Dock */}
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ duration: 0.3 }}
className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-campfire-charcoal/80 backdrop-blur-md border-t border-campfire-ash/20"
>
<div className="container-custom py-2">
<Dock
items={mainItems}
className="bg-campfire-charcoal/50 backdrop-blur-sm rounded-md"
distance={120}
panelHeight={50}
baseItemSize={32}
dockHeight={30}
magnification={40}
spring={{ mass: 0.2, stiffness: 200, damping: 15 }}
/>
</div>
</motion.div>
{/* Alpha Badge */}
<AlphaBadge />
</>
);
};
export default Header;

View File

@ -0,0 +1,34 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
import { useAuth } from '../../contexts/AuthContext'; // Import useAuth
console.log('Layout.jsx: Rendering Layout component'); // Add logging
function Layout() {
const { isInitialized } = useAuth(); // Use isInitialized from auth context
// Optionally render a loading state if auth is not initialized
if (!isInitialized) {
console.log('Layout.jsx: Auth not initialized, rendering loading state.'); // Add logging
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
console.log('Layout.jsx: Auth initialized, rendering Header, Outlet, and Footer.'); // Add logging
return (
<div className="flex flex-col min-h-screen bg-campfire-dark text-campfire-light">
<Header />
{/* Removed container-custom and mx-auto to allow content to stretch */}
<main className="flex-grow px-4 py-8">
<Outlet /> {/* Renders the matched route's component */}
</main>
<Footer />
</div>
);
}
export default Layout;

View File

@ -0,0 +1,79 @@
import React, { useRef, useEffect } from 'react';
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
// Custom Media Carousel component with mouse scroll and auto-scroll
const CustomMediaCarousel = ({ media, userProfile }) => {
const carouselRef = useRef(null);
const scrollIntervalRef = useRef(null);
// Auto-scroll logic
useEffect(() => {
const carousel = carouselRef.current;
if (!carousel) return;
// Start auto-scroll
scrollIntervalRef.current = setInterval(() => {
// Scroll by the width of one card (approx)
const cardWidth = carousel.querySelector('.flex-shrink-0')?.offsetWidth || 256; // Default width if no card found
const currentScroll = carousel.scrollLeft;
const maxScroll = carousel.scrollWidth - carousel.clientWidth;
if (currentScroll >= maxScroll) {
// If at the end, jump back to the beginning smoothly
carousel.scrollTo({ left: 0, behavior: 'smooth' });
} else {
// Scroll forward
carousel.scrollBy({ left: cardWidth + 16, behavior: 'smooth' }); // Add gap (16px = space-x-4)
}
}, 3000); // Scroll every 3 seconds
// Clear interval on user interaction (mouse wheel, drag)
const handleUserInteraction = () => {
clearInterval(scrollIntervalRef.current);
};
carousel.addEventListener('wheel', handleUserInteraction);
// Add more event listeners for drag/touch if needed, but wheel covers mouse scroll
// Clean up interval and event listeners on component unmount
return () => {
clearInterval(scrollIntervalRef.current);
carousel.removeEventListener('wheel', handleUserInteraction);
};
}, [media]); // Re-run effect if media changes
if (!media || media.length === 0) {
return <div className="text-campfire-ash text-center">Нет контента для отображения.</div>;
}
return (
// Use flexbox for horizontal layout and overflow-x-auto for scrolling
// Add padding-bottom to prevent scrollbar from covering content
// Added custom scrollbar styles via Tailwind config (if configured) or direct CSS
// Added ref for scrolling
<div
ref={carouselRef}
className="flex overflow-x-auto space-x-8 pb-4 scrollbar-thin scrollbar-thumb-campfire-amber scrollbar-track-campfire-charcoal"
style={{ scrollSnapType: 'x mandatory' }} // Optional: Add scroll snap for smoother card-by-card scrolling
>
{media.map((mediaItem) => (
// Wrap MediaCard in a div with flex-shrink-0 to prevent shrinking
// Set a fixed width for each card in the carousel
<div
key={mediaItem.id}
className="flex-shrink-0 w-40 sm:w-48 md:w-56 lg:w-64" // Adjust width as needed
style={{ scrollSnapAlign: 'start' }} // Optional: Align snap to the start of the card
>
<TiltedCard
imageSrc={mediaItem.poster_path}
captionText={mediaItem.title}
rating={mediaItem.rating}
releaseDate={mediaItem.release_date}
/>
</div>
))}
</div>
);
};
export default CustomMediaCarousel;

View File

@ -0,0 +1,89 @@
import { Link } from 'react-router-dom';
import { FaFire } from 'react-icons/fa'; // Changed FaStar to FaFire
import { getFileUrl } from '../../services/pocketbaseService'; // Corrected import path
import { useEffect } from 'react'; // Import useEffect for logging
import { useAuth } from '../../contexts/AuthContext'; // Import useAuth
// MediaCard component displays a card for a media item.
// It now accepts averageRating and reviewCount props from the parent.
// Added userProfile prop to check admin/critic role
function MediaCard({ media, userProfile }) {
// Destructure media properties, including the new ones from Supabase
// Use 'path' for the link instead of 'id'
// Используем 'poster' вместо 'poster_url' для имени файла
const { id, title, poster, average_rating, review_count, release_date, type, path, is_published } = media; // Added is_published
// Используем getFileUrl для получения URL постера
const posterUrl = getFileUrl(media, 'poster'); // Используем имя поля 'poster'
const releaseDate = release_date;
const averageRating = average_rating; // This is the 10-point average from Supabase
const reviewCount = review_count;
// Use the 'path' field for the link
const mediaLink = path ? `/media/${path}` : `/media/${id}`; // Fallback to id if path is missing (for old data)
// Check if the current user is admin or critic
const isAdminOrCritic = userProfile && (userProfile.role === 'admin' || userProfile.is_critic === true);
// Add log to check average_rating and review_count here when component mounts or media prop changes
useEffect(() => {
console.log(`MediaCard for "${title}" rendered with media prop:`, media);
console.log(`MediaCard for "${title}" stats:`, {
average_rating: averageRating,
review_count: reviewCount,
is_published: is_published
});
}, [media]);
return (
<Link to={mediaLink} className="block">
<div className="card group h-full bg-campfire-charcoal rounded-lg overflow-hidden shadow-lg border border-campfire-ash/20 transition-all duration-300 hover:shadow-xl hover:border-campfire-amber/30 relative"> {/* Added relative for absolute positioning */}
<div className="relative overflow-hidden aspect-[2/3]">
{/* Используем posterUrl, полученный через getFileUrl */}
<img
src={posterUrl}
alt={title}
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{/* Display average rating and review count */}
{/* Проверяем, что average_rating не null и не undefined перед отображением */}
{/* Ensure average_rating is treated as a number */}
{average_rating !== null && average_rating !== undefined && !isNaN(parseFloat(average_rating)) && (
<div className="absolute top-2 right-2 bg-campfire-dark bg-opacity-75 rounded-full px-2 py-1 flex items-center">
<FaFire className="text-campfire-amber mr-1" size={14} /> {/* Changed FaStar to FaFire */}
<span className="text-sm font-medium text-campfire-light">
{/* Форматируем average_rating до одной десятичной */}
{parseFloat(average_rating).toFixed(1)} / 10
</span>
{/* Optionally display review count */}
{/* {review_count !== null && review_count !== undefined && (
<span className="text-xs text-campfire-ash ml-1">({review_count})</span>
)} */}
</div>
)}
{/* Admin/Critic mark for unpublished media */}
{isAdminOrCritic && !is_published && (
<div className="absolute bottom-2 left-2 bg-red-600 bg-opacity-75 text-white text-xs font-semibold px-2 py-1 rounded-full">
Не опубликовано
</div>
)}
</div>
<div className="p-4">
<h3 className="font-bold text-campfire-light line-clamp-1 mb-1 group-hover:text-campfire-amber transition-colors">
{title}
</h3>
<div className="text-sm text-campfire-ash">
{releaseDate ? new Date(releaseDate).getFullYear() : 'N/A'}
</div>
</div>
</div>
</Link>
);
}
export default MediaCard;

View File

@ -0,0 +1,30 @@
import React from 'react';
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
// Simple Media Carousel component
const MediaCarousel = ({ media, userProfile }) => {
if (!media || media.length === 0) {
return <div className="text-campfire-ash text-center">Нет контента для отображения.</div>;
}
return (
// Use flexbox for horizontal layout and overflow-x-auto for scrolling
// Add padding-bottom to prevent scrollbar from covering content
<div className="flex overflow-x-auto space-x-8 pb-4 scrollbar-thin scrollbar-thumb-campfire-amber scrollbar-track-campfire-charcoal">
{media.map((mediaItem) => (
// Wrap MediaCard in a div with flex-shrink-0 to prevent shrinking
// Set a fixed width for each card in the carousel
<div key={mediaItem.id} className="flex-shrink-0 w-40 sm:w-48 md:w-56 lg:w-64"> {/* Adjust width as needed */}
<TiltedCard
imageSrc={mediaItem.poster_path}
captionText={mediaItem.title}
rating={mediaItem.rating}
releaseDate={mediaItem.release_date}
/>
</div>
))}
</div>
);
};
export default MediaCarousel;

View File

@ -0,0 +1,72 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { getFileUrl } from '../../utils/fileUtils';
const ProfileMenu = ({ isOpen, onClose, user, onSignOut }) => {
const navigate = useNavigate();
const menuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-end p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div
ref={menuRef}
className="relative bg-campfire-dark border border-campfire-gold/20 rounded-lg shadow-lg w-64 overflow-hidden"
>
<div className="p-4 border-b border-campfire-gold/20">
<div className="flex items-center space-x-3">
<img
src={getFileUrl(user, 'profile_picture', { thumb: '100x100' }) || '/default-avatar.png'}
alt={user.username}
className="w-10 h-10 rounded-full object-cover"
/>
<div>
<h3 className="text-campfire-light font-semibold">{user.username}</h3>
<p className="text-campfire-ash text-sm">Уровень {user.level}</p>
</div>
</div>
</div>
<div className="py-2">
<button
onClick={() => {
navigate(`/profile/${user.username}`);
onClose();
}}
className="w-full px-4 py-2 text-left text-campfire-light hover:bg-campfire-gold/10 transition-colors"
>
Профиль
</button>
<button
onClick={() => {
onSignOut();
onClose();
}}
className="w-full px-4 py-2 text-left text-campfire-light hover:bg-campfire-gold/10 transition-colors"
>
Выйти
</button>
</div>
</div>
</div>
);
};
export default ProfileMenu;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { FaEdit } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
const ProfileHeader = ({ user, isOwnProfile }) => {
const navigate = useNavigate();
const handleEditClick = () => {
navigate('/profile/settings');
};
return (
<div className="relative">
<div
className="relative cursor-pointer group"
onClick={isOwnProfile ? handleEditClick : undefined}
>
<img
src={user?.avatar || '/default-avatar.png'}
alt={user?.username}
className="w-32 h-32 rounded-full object-cover border-4 border-campfire-primary"
/>
{isOwnProfile && (
<div className="absolute inset-0 bg-campfire-dark/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<FaEdit className="text-campfire-light text-2xl" />
</div>
)}
</div>
<h1 className="text-3xl font-bold text-campfire-light mt-4">{user?.username}</h1>
{user?.bio && (
<p className="text-campfire-light/60 mt-2">{user.bio}</p>
)}
</div>
);
};
export default ProfileHeader;

View File

@ -0,0 +1,159 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from "react";
const ClickSpark = forwardRef(({
sparkColor = "#fff",
sparkSize = 10,
sparkRadius = 15,
sparkCount = 8,
duration = 400,
easing = "ease-out",
extraScale = 1.0,
children
}, ref) => {
const canvasRef = useRef(null);
const sparksRef = useRef([]);
const startTimeRef = useRef(null);
useImperativeHandle(ref, () => ({
addSpark: (x, y) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const sparkX = x - rect.left;
const sparkY = y - rect.top;
const now = performance.now();
const newSparks = Array.from({ length: sparkCount }, (_, i) => ({
x: sparkX,
y: sparkY,
angle: (2 * Math.PI * i) / sparkCount,
startTime: now,
}));
sparksRef.current.push(...newSparks);
}
}));
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
let resizeTimeout;
const resizeCanvas = () => {
const { width, height } = parent.getBoundingClientRect();
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
};
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(resizeCanvas, 100);
};
const ro = new ResizeObserver(handleResize);
ro.observe(parent);
resizeCanvas();
return () => {
ro.disconnect();
clearTimeout(resizeTimeout);
};
}, []);
const easeFunc = useCallback(
(t) => {
switch (easing) {
case "linear":
return t;
case "ease-in":
return t * t;
case "ease-in-out":
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
default:
return t * (2 - t);
}
},
[easing]
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
let animationId;
const draw = (timestamp) => {
if (!startTimeRef.current) {
startTimeRef.current = timestamp;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
sparksRef.current = sparksRef.current.filter((spark) => {
const elapsed = timestamp - spark.startTime;
if (elapsed >= duration) {
return false;
}
const progress = elapsed / duration;
const eased = easeFunc(progress);
const distance = eased * sparkRadius * extraScale;
const lineLength = sparkSize * (1 - eased);
const x1 = spark.x + distance * Math.cos(spark.angle);
const y1 = spark.y + distance * Math.sin(spark.angle);
const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle);
const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle);
ctx.strokeStyle = sparkColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
return true;
});
animationId = requestAnimationFrame(draw);
};
animationId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animationId);
};
}, [
sparkColor,
sparkSize,
sparkRadius,
sparkCount,
duration,
easeFunc,
extraScale,
]);
return (
<div className="relative w-full h-full">
<canvas
ref={canvasRef}
className="w-full h-full block absolute top-0 left-0 select-none pointer-events-none"
/>
{children}
</div>
);
});
export default ClickSpark;

View File

@ -0,0 +1,105 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
const GridMotion = ({ items = [], gradientColor = 'black' }) => {
const gridRef = useRef(null);
const rowRefs = useRef([]); // Array of refs for each row
const mouseXRef = useRef(window.innerWidth / 2);
// Ensure the grid has 28 items (4 rows x 7 columns) by default
const totalItems = 28;
const defaultItems = Array.from({ length: totalItems }, (_, index) => `Item ${index + 1}`);
const combinedItems = items.length > 0 ? items.slice(0, totalItems) : defaultItems;
useEffect(() => {
gsap.ticker.lagSmoothing(0);
const handleMouseMove = (e) => {
mouseXRef.current = e.clientX;
};
const updateMotion = () => {
const maxMoveAmount = 300;
const baseDuration = 0.8; // Base duration for inertia
const inertiaFactors = [0.6, 0.4, 0.3, 0.2]; // Different inertia for each row, outer rows slower
rowRefs.current.forEach((row, index) => {
if (row) {
const direction = index % 2 === 0 ? 1 : -1;
const moveAmount = ((mouseXRef.current / window.innerWidth) * maxMoveAmount - maxMoveAmount / 2) * direction;
// Apply inertia and staggered stop
gsap.to(row, {
x: moveAmount,
duration: baseDuration + inertiaFactors[index % inertiaFactors.length],
ease: 'power3.out',
overwrite: 'auto',
});
}
});
};
const removeAnimationLoop = gsap.ticker.add(updateMotion);
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
removeAnimationLoop();
};
}, []);
return (
<div ref={gridRef} className="h-full w-full overflow-hidden">
<section
className="w-full h-screen overflow-hidden relative flex items-center justify-center"
style={{
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 100%)`,
}}
>
{/* Noise overlay */}
<div
className="absolute inset-0 pointer-events-none z-[4] bg-[url('../../../assets/noise.png')] bg-[length:250px]"
></div>
<div
className="gap-4 flex-none relative w-[150vw] h-[150vh] grid grid-rows-4 grid-cols-1 rotate-[-15deg] origin-center z-[2]"
>
{[...Array(4)].map((_, rowIndex) => (
<div
key={rowIndex}
className="grid gap-4 grid-cols-7"
style={{ willChange: 'transform, filter' }}
ref={(el) => (rowRefs.current[rowIndex] = el)}
>
{[...Array(7)].map((_, itemIndex) => {
const content = combinedItems[rowIndex * 7 + itemIndex];
return (
<div key={itemIndex} className="relative">
<div
className="relative w-full h-full overflow-hidden rounded-[10px] bg-[#111] flex items-center justify-center text-white text-[1.5rem]"
>
{typeof content === 'string' && content.startsWith('http') ? (
<div
className="w-full h-full bg-cover bg-center absolute top-0 left-0"
style={{ backgroundImage: `url(${content})` }}
></div>
) : (
<div className="p-4 text-center z-[1]">{content}</div>
)}
</div>
</div>
);
})}
</div>
))}
</div>
<div className="relative w-full h-full top-0 left-0 pointer-events-none"></div>
</section>
</div>
);
};
export default GridMotion;

View File

@ -0,0 +1,44 @@
.dock-item {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s ease-in-out;
background-color: rgba(23, 23, 23, 0.5);
backdrop-filter: blur(8px);
}
.dock-item:hover {
transform: scale(1.1);
background-color: rgba(251, 146, 60, 0.1);
}
.dock-item.active {
background-color: rgba(251, 146, 60, 0.2);
}
.dock-item-icon {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.dock-item-label {
font-size: 0.7rem;
margin-top: 0.25rem;
text-align: center;
color: #e5e5e5;
opacity: 0.8;
}
.dock-container {
background: rgba(23, 23, 23, 0.3);
backdrop-filter: blur(8px);
border-radius: 0.5rem;
padding: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

View File

@ -0,0 +1,185 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
"use client";
import {
motion,
useMotionValue,
useSpring,
useTransform,
AnimatePresence,
} from "framer-motion";
import {
Children,
cloneElement,
useEffect,
useMemo,
useRef,
useState,
} from "react";
function DockItem({
children,
className = "",
onClick,
mouseX,
spring,
distance,
magnification,
baseItemSize,
showLabel = true,
isRectangular = false,
}) {
const ref = useRef(null);
const isHovered = useMotionValue(0);
const mouseDistance = useTransform(mouseX, (val) => {
const rect = ref.current?.getBoundingClientRect() ?? {
x: 0,
width: baseItemSize,
};
return val - rect.x - baseItemSize / 2;
});
const targetSize = useTransform(
mouseDistance,
[-distance, 0, distance],
[baseItemSize, magnification, baseItemSize]
);
const size = useSpring(targetSize, spring);
return (
<div className="flex items-center">
<motion.div
ref={ref}
style={{
width: isRectangular ? 'auto' : size,
height: size,
minWidth: isRectangular ? baseItemSize * 2 : size,
}}
onHoverStart={() => isHovered.set(1)}
onHoverEnd={() => isHovered.set(0)}
onFocus={() => isHovered.set(1)}
onBlur={() => isHovered.set(0)}
onClick={onClick}
className={`relative inline-flex items-center justify-center rounded-md bg-[#060606] border-neutral-700 border-2 shadow-md ${className}`}
tabIndex={0}
role="button"
aria-haspopup="true"
>
{Children.map(children, (child) =>
cloneElement(child, { isHovered, showLabel })
)}
</motion.div>
</div>
);
}
function DockLabel({ children, className = "", showLabel = true, ...rest }) {
const { isHovered } = rest;
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const unsubscribe = isHovered.on("change", (latest) => {
setIsVisible(latest === 1);
});
return () => unsubscribe();
}, [isHovered]);
if (!showLabel) {
return (
<span className={`${className} text-[10px] text-campfire-light ml-2`}>
{children}
</span>
);
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 10 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 0.2 }}
className={`${className} absolute -bottom-6 left-1/2 w-fit whitespace-pre rounded-md border border-neutral-700 bg-[#060606] px-2 py-0.5 text-xs text-white`}
role="tooltip"
style={{ x: "-50%" }}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
function DockIcon({ children, className = "" }) {
return (
<div className={`flex items-center justify-center ${className}`}>
{children}
</div>
);
}
export default function Dock({
items,
className = "",
spring = { mass: 0.1, stiffness: 150, damping: 12 },
magnification = 70,
distance = 200,
panelHeight = 64,
dockHeight = 256,
baseItemSize = 50,
}) {
const mouseX = useMotionValue(Infinity);
const isHovered = useMotionValue(0);
const maxHeight = useMemo(
() => Math.max(dockHeight, magnification + magnification / 2 + 4),
[magnification, dockHeight]
);
const heightRow = useTransform(isHovered, [0, 1], [panelHeight, maxHeight]);
const height = useSpring(heightRow, spring);
return (
<motion.div
style={{ height, scrollbarWidth: "none" }}
className="flex items-center"
>
<motion.div
onMouseMove={({ pageX }) => {
isHovered.set(1);
mouseX.set(pageX);
}}
onMouseLeave={() => {
isHovered.set(0);
mouseX.set(Infinity);
}}
className={`${className} flex items-end w-fit gap-4 rounded-2xl border-neutral-700 border-2 pb-2 px-4`}
style={{ height: panelHeight }}
role="toolbar"
aria-label="Application dock"
>
{items.map((item, index) => (
<DockItem
key={index}
onClick={item.onClick}
className={item.className}
mouseX={mouseX}
spring={spring}
distance={distance}
magnification={magnification}
baseItemSize={baseItemSize}
showLabel={item.showLabel}
isRectangular={item.isRectangular}
>
<DockIcon>{item.icon}</DockIcon>
<DockLabel>{item.label}</DockLabel>
</DockItem>
))}
</motion.div>
</motion.div>
);
}

View File

@ -0,0 +1,280 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import React, { useState, Children, useRef, useLayoutEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
export default function Stepper({
children,
initialStep = 1,
onStepChange = () => { },
onFinalStepCompleted = () => { },
stepCircleContainerClassName = "",
stepContainerClassName = "",
contentClassName = "",
footerClassName = "",
backButtonProps = {},
nextButtonProps = {},
backButtonText = "Back",
nextButtonText = "Continue",
disableStepIndicators = false,
renderStepIndicator,
...rest
}) {
const [currentStep, setCurrentStep] = useState(initialStep);
const [direction, setDirection] = useState(0);
const stepsArray = Children.toArray(children);
const totalSteps = stepsArray.length;
const isCompleted = currentStep > totalSteps;
const isLastStep = currentStep === totalSteps;
const updateStep = (newStep) => {
setCurrentStep(newStep);
if (newStep > totalSteps) onFinalStepCompleted();
else onStepChange(newStep);
};
const handleBack = () => {
if (currentStep > 1) {
setDirection(-1);
updateStep(currentStep - 1);
}
};
const handleNext = () => {
if (!isLastStep) {
setDirection(1);
updateStep(currentStep + 1);
}
};
const handleComplete = () => {
setDirection(1);
updateStep(totalSteps + 1);
};
return (
<div
className="flex min-h-full flex-1 flex-col items-center justify-center p-4 sm:aspect-[4/3] md:aspect-[2/1]"
{...rest}
>
<div
className={`mx-auto w-full max-w-md rounded-4xl shadow-xl ${stepCircleContainerClassName}`}
style={{ border: "1px solid #222" }}
>
<div className={`${stepContainerClassName} flex w-full items-center p-8`}>
{stepsArray.map((_, index) => {
const stepNumber = index + 1;
const isNotLastStep = index < totalSteps - 1;
return (
<React.Fragment key={stepNumber}>
{renderStepIndicator ? (
renderStepIndicator({
step: stepNumber,
currentStep,
onStepClick: (clicked) => {
setDirection(clicked > currentStep ? 1 : -1);
updateStep(clicked);
},
})
) : (
<StepIndicator
step={stepNumber}
disableStepIndicators={disableStepIndicators}
currentStep={currentStep}
onClickStep={(clicked) => {
setDirection(clicked > currentStep ? 1 : -1);
updateStep(clicked);
}}
/>
)}
{isNotLastStep && (
<StepConnector isComplete={currentStep > stepNumber} />
)}
</React.Fragment>
);
})}
</div>
<StepContentWrapper
isCompleted={isCompleted}
currentStep={currentStep}
direction={direction}
className={`space-y-2 px-8 ${contentClassName}`}
>
{stepsArray[currentStep - 1]}
</StepContentWrapper>
{!isCompleted && (
<div className={`px-8 pb-8 ${footerClassName}`}>
<div
className={`mt-10 flex ${currentStep !== 1 ? "justify-between" : "justify-end"
}`}
>
{currentStep !== 1 && (
<button
onClick={handleBack}
className={`duration-350 rounded px-2 py-1 transition ${currentStep === 1
? "pointer-events-none opacity-50 text-neutral-400"
: "text-neutral-400 hover:text-neutral-700"
}`}
{...backButtonProps}
>
{backButtonText}
</button>
)}
<button
onClick={isLastStep ? handleComplete : handleNext}
className="duration-350 flex items-center justify-center rounded-full bg-green-500 py-1.5 px-3.5 font-medium tracking-tight text-white transition hover:bg-green-600 active:bg-green-700"
{...nextButtonProps}
>
{isLastStep ? "Complete" : nextButtonText}
</button>
</div>
</div>
)}
</div>
</div>
);
}
function StepContentWrapper({ isCompleted, currentStep, direction, children, className }) {
const [parentHeight, setParentHeight] = useState(0);
return (
<motion.div
style={{ position: "relative", overflow: "hidden" }}
animate={{ height: isCompleted ? 0 : parentHeight }}
transition={{ type: "spring", duration: 0.4 }}
className={className}
>
<AnimatePresence initial={false} mode="sync" custom={direction}>
{!isCompleted && (
<SlideTransition
key={currentStep}
direction={direction}
onHeightReady={(h) => setParentHeight(h)}
>
{children}
</SlideTransition>
)}
</AnimatePresence>
</motion.div>
);
}
function SlideTransition({ children, direction, onHeightReady }) {
const containerRef = useRef(null);
useLayoutEffect(() => {
if (containerRef.current) onHeightReady(containerRef.current.offsetHeight);
}, [children, onHeightReady]);
return (
<motion.div
ref={containerRef}
custom={direction}
variants={stepVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.4 }}
style={{ position: "absolute", left: 0, right: 0, top: 0 }}
>
{children}
</motion.div>
);
}
const stepVariants = {
enter: (dir) => ({
x: dir >= 0 ? "-100%" : "100%",
opacity: 0,
}),
center: {
x: "0%",
opacity: 1,
},
exit: (dir) => ({
x: dir >= 0 ? "50%" : "-50%",
opacity: 0,
}),
};
export function Step({ children }) {
return <div className="px-8">{children}</div>;
}
function StepIndicator({ step, currentStep, onClickStep, disableStepIndicators }) {
const status = currentStep === step ? "active" : currentStep < step ? "inactive" : "complete";
const handleClick = () => {
if (step !== currentStep && !disableStepIndicators) onClickStep(step);
};
return (
<motion.div
onClick={handleClick}
className="relative cursor-pointer outline-none focus:outline-none"
animate={status}
initial={false}
>
<motion.div
variants={{
inactive: { scale: 1, backgroundColor: "#222", color: "#a3a3a3" },
active: { scale: 1, backgroundColor: "#00d8ff", color: "#00d8ff" },
complete: { scale: 1, backgroundColor: "#00d8ff", color: "#3b82f6" },
}}
transition={{ duration: 0.3 }}
className="flex h-8 w-8 items-center justify-center rounded-full font-semibold"
>
{status === "complete" ? (
<CheckIcon className="h-4 w-4 text-black" />
) : status === "active" ? (
<div className="h-3 w-3 rounded-full bg-[#060606]" />
) : (
<span className="text-sm">{step}</span>
)}
</motion.div>
</motion.div>
);
}
function StepConnector({ isComplete }) {
const lineVariants = {
incomplete: { width: 0, backgroundColor: "transparent" },
complete: { width: "100%", backgroundColor: "#00d8ff" },
};
return (
<div className="relative mx-2 h-0.5 flex-1 overflow-hidden rounded bg-neutral-600">
<motion.div
className="absolute left-0 top-0 h-full"
variants={lineVariants}
initial={false}
animate={isComplete ? "complete" : "incomplete"}
transition={{ duration: 0.4 }}
/>
</div>
);
}
function CheckIcon(props) {
return (
<svg
{...props}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<motion.path
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ delay: 0.1, type: "tween", ease: "easeOut", duration: 0.3 }}
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
);
}

View File

@ -0,0 +1,232 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useRef, useState, useEffect } from "react";
import { motion, useMotionValue, useSpring } from "framer-motion";
import { FaFire } from "react-icons/fa";
const springValues = {
damping: 30,
stiffness: 100,
mass: 2,
};
export default function TiltedCard({
imageSrc,
altText = "Tilted card image",
captionText = "",
containerHeight = "300px",
containerWidth = "100%",
imageHeight = "300px",
imageWidth = "300px",
scaleOnHover = 1.1,
rotateAmplitude = 14,
showMobileWarning = true,
showTooltip = true,
overlayContent = null,
displayOverlayContent = false,
rating = null,
releaseDate = null
}) {
const ref = useRef(null);
const [isDark, setIsDark] = useState(true);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateX = useSpring(useMotionValue(0), springValues);
const rotateY = useSpring(useMotionValue(0), springValues);
const scale = useSpring(1, springValues);
const opacity = useSpring(0);
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1,
});
const [lastY, setLastY] = useState(0);
useEffect(() => {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = imageSrc;
img.onload = () => {
// Создаем временный canvas для анализа цвета
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Устанавливаем размер canvas равным размеру изображения
canvas.width = img.width;
canvas.height = img.height;
// Рисуем изображение
ctx.drawImage(img, 0, 0);
try {
// Получаем данные только из нижней трети изображения
const bottomThird = Math.floor(img.height * 0.66);
const imageData = ctx.getImageData(0, bottomThird, img.width, img.height - bottomThird);
const data = imageData.data;
let r = 0, g = 0, b = 0;
let count = 0;
// Анализируем каждый 4-й пиксель для производительности
for (let i = 0; i < data.length; i += 16) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
// Вычисляем средний цвет
r = Math.floor(r / count);
g = Math.floor(g / count);
b = Math.floor(b / count);
// Вычисляем яркость по формуле
const brightness = (r * 0.299 + g * 0.587 + b * 0.114);
// Если яркость больше 128, считаем фон светлым
setIsDark(brightness < 128);
} catch (error) {
// В случае ошибки CORS, используем темный фон по умолчанию
console.warn('Не удалось определить яркость изображения:', error);
setIsDark(true);
}
};
img.onerror = () => {
// В случае ошибки загрузки изображения, используем темный фон
setIsDark(true);
};
}, [imageSrc]);
function handleMouse(e) {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const offsetX = e.clientX - rect.left - rect.width / 2;
const offsetY = e.clientY - rect.top - rect.height / 2;
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
rotateX.set(rotationX);
rotateY.set(rotationY);
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
const velocityY = offsetY - lastY;
rotateFigcaption.set(-velocityY * 0.6);
setLastY(offsetY);
}
function handleMouseEnter() {
scale.set(scaleOnHover);
opacity.set(1);
}
function handleMouseLeave() {
opacity.set(0);
scale.set(1);
rotateX.set(0);
rotateY.set(0);
rotateFigcaption.set(0);
}
const formatYear = (dateString) => {
if (!dateString) return '';
return new Date(dateString).getFullYear();
};
return (
<figure
ref={ref}
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
style={{
height: containerHeight,
width: containerWidth,
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showMobileWarning && (
<div className="absolute top-4 text-center text-sm block sm:hidden">
This effect is not optimized for mobile. Check on desktop.
</div>
)}
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale,
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
style={{
width: imageWidth,
height: imageHeight,
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div
className="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]"
>
{overlayContent}
</motion.div>
)}
{rating !== null && rating !== undefined && (
<motion.div
className="absolute top-3 right-3 z-[3] will-change-transform [transform:translateZ(40px)]"
>
<div className="bg-campfire-amber text-campfire-dark px-3 py-1.5 rounded-full shadow-lg font-bold text-sm flex items-center gap-1.5">
<FaFire className="text-campfire-dark text-sm" />
<span>{rating}</span>
</div>
</motion.div>
)}
<motion.div
className="absolute bottom-0 left-0 right-0 z-[3] will-change-transform [transform:translateZ(40px)]"
>
<div className={`bg-gradient-to-t ${isDark ? 'from-campfire-dark/90' : 'from-campfire-light/90'} to-transparent p-3 rounded-b-[15px] text-center`}>
<h3 className={`text-base font-bold line-clamp-2 ${isDark ? 'text-campfire-light' : 'text-campfire-dark'}`}>
{captionText}
</h3>
{releaseDate && (
<p className={`text-sm mt-1 ${isDark ? 'text-campfire-light/80' : 'text-campfire-dark/80'}`}>
{formatYear(releaseDate)}
</p>
)}
</div>
</motion.div>
</motion.div>
{showTooltip && (
<motion.figcaption
className="pointer-events-none absolute left-0 top-0 rounded-[4px] bg-white px-[10px] py-[4px] text-[10px] text-[#2d2d2d] opacity-0 z-[3] hidden sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption,
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
);
}

View File

@ -0,0 +1,103 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useEffect, useRef } from "react";
import { useInView, useMotionValue, useSpring } from "framer-motion";
export default function CountUp({
to,
from = 0,
direction = "up",
delay = 0,
duration = 2, // Duration of the animation in seconds
className = "",
startWhen = true,
separator = "",
onStart,
onEnd,
}) {
const ref = useRef(null);
const motionValue = useMotionValue(direction === "down" ? to : from);
// Calculate damping and stiffness based on duration
const damping = 20 + 40 * (1 / duration); // Adjust this formula for finer control
const stiffness = 100 * (1 / duration); // Adjust this formula for finer control
const springValue = useSpring(motionValue, {
damping,
stiffness,
});
const isInView = useInView(ref, { once: true, margin: "0px" });
// Set initial text content to the initial value based on direction
useEffect(() => {
if (ref.current) {
ref.current.textContent = String(direction === "down" ? to : from);
}
}, [from, to, direction]);
// Start the animation when in view and startWhen is true
useEffect(() => {
if (isInView && startWhen) {
if (typeof onStart === "function") {
onStart();
}
const timeoutId = setTimeout(() => {
motionValue.set(direction === "down" ? from : to);
}, delay * 1000);
const durationTimeoutId = setTimeout(
() => {
if (typeof onEnd === "function") {
onEnd();
}
},
delay * 1000 + duration * 1000
);
return () => {
clearTimeout(timeoutId);
clearTimeout(durationTimeoutId);
};
}
}, [
isInView,
startWhen,
motionValue,
direction,
from,
to,
delay,
onStart,
onEnd,
duration,
]);
// Update text content with formatted number on spring value change
useEffect(() => {
const unsubscribe = springValue.on("change", (latest) => {
if (ref.current) {
const options = {
useGrouping: !!separator,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
};
const formattedNumber = Intl.NumberFormat("en-US", options).format(
latest.toFixed(0)
);
ref.current.textContent = separator
? formattedNumber.replace(/,/g, separator)
: formattedNumber;
}
});
return () => unsubscribe();
}, [springValue, separator]);
return <span className={`${className}`} ref={ref} />;
}

View File

@ -0,0 +1,205 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import React, { useEffect, useRef } from "react";
const FuzzyText = ({
children,
fontSize = "clamp(2rem, 10vw, 10rem)",
fontWeight = 900,
fontFamily = "inherit",
color = "#fff",
enableHover = true,
baseIntensity = 0.18,
hoverIntensity = 0.5,
}) => {
const canvasRef = useRef(null);
useEffect(() => {
let animationFrameId;
let isCancelled = false;
const canvas = canvasRef.current;
if (!canvas) return;
const init = async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
if (isCancelled) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const computedFontFamily =
fontFamily === "inherit"
? window.getComputedStyle(canvas).fontFamily || "sans-serif"
: fontFamily;
const fontSizeStr =
typeof fontSize === "number" ? `${fontSize}px` : fontSize;
let numericFontSize;
if (typeof fontSize === "number") {
numericFontSize = fontSize;
} else {
const temp = document.createElement("span");
temp.style.fontSize = fontSize;
document.body.appendChild(temp);
const computedSize = window.getComputedStyle(temp).fontSize;
numericFontSize = parseFloat(computedSize);
document.body.removeChild(temp);
}
const text = React.Children.toArray(children).join("");
// Create offscreen canvas
const offscreen = document.createElement("canvas");
const offCtx = offscreen.getContext("2d");
if (!offCtx) return;
offCtx.font = `${fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.textBaseline = "alphabetic";
const metrics = offCtx.measureText(text);
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const actualDescent =
metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
const tightHeight = Math.ceil(actualAscent + actualDescent);
const extraWidthBuffer = 10;
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
offscreen.width = offscreenWidth;
offscreen.height = tightHeight;
const xOffset = extraWidthBuffer / 2;
offCtx.font = `${fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.textBaseline = "alphabetic";
offCtx.fillStyle = color;
offCtx.fillText(text, xOffset - actualLeft, actualAscent);
const horizontalMargin = 50;
const verticalMargin = 0;
canvas.width = offscreenWidth + horizontalMargin * 2;
canvas.height = tightHeight + verticalMargin * 2;
ctx.translate(horizontalMargin, verticalMargin);
const interactiveLeft = horizontalMargin + xOffset;
const interactiveTop = verticalMargin;
const interactiveRight = interactiveLeft + textBoundingWidth;
const interactiveBottom = interactiveTop + tightHeight;
let isHovering = false;
const fuzzRange = 30;
const run = () => {
if (isCancelled) return;
ctx.clearRect(
-fuzzRange,
-fuzzRange,
offscreenWidth + 2 * fuzzRange,
tightHeight + 2 * fuzzRange
);
const intensity = isHovering ? hoverIntensity : baseIntensity;
for (let j = 0; j < tightHeight; j++) {
const dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange);
ctx.drawImage(
offscreen,
0,
j,
offscreenWidth,
1,
dx,
j,
offscreenWidth,
1
);
}
animationFrameId = window.requestAnimationFrame(run);
};
run();
const isInsideTextArea = (x, y) => {
return (
x >= interactiveLeft &&
x <= interactiveRight &&
y >= interactiveTop &&
y <= interactiveBottom
);
};
const handleMouseMove = (e) => {
if (!enableHover) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleMouseLeave = () => {
isHovering = false;
};
const handleTouchMove = (e) => {
if (!enableHover) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleTouchEnd = () => {
isHovering = false;
};
if (enableHover) {
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseleave", handleMouseLeave);
canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
canvas.addEventListener("touchend", handleTouchEnd);
}
const cleanup = () => {
window.cancelAnimationFrame(animationFrameId);
if (enableHover) {
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mouseleave", handleMouseLeave);
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
}
};
canvas.cleanupFuzzyText = cleanup;
};
init();
return () => {
isCancelled = true;
window.cancelAnimationFrame(animationFrameId);
if (canvas && canvas.cleanupFuzzyText) {
canvas.cleanupFuzzyText();
}
};
}, [
children,
fontSize,
fontWeight,
fontFamily,
color,
enableHover,
baseIntensity,
hoverIntensity,
]);
return <canvas ref={canvasRef} />;
};
export default FuzzyText;

View File

@ -0,0 +1,222 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import { motion, AnimatePresence } from "framer-motion";
function cn(...classes) {
return classes.filter(Boolean).join(" ");
}
const RotatingText = forwardRef((props, ref) => {
const {
texts,
transition = { type: "spring", damping: 25, stiffness: 300 },
initial = { y: "100%", opacity: 0 },
animate = { y: 0, opacity: 1 },
exit = { y: "-120%", opacity: 0 },
animatePresenceMode = "wait",
animatePresenceInitial = false,
rotationInterval = 2000,
staggerDuration = 0,
staggerFrom = "first",
loop = true,
auto = true,
splitBy = "characters",
onNext,
mainClassName,
splitLevelClassName,
elementLevelClassName,
...rest
} = props;
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const splitIntoCharacters = (text) => {
if (typeof Intl !== "undefined" && Intl.Segmenter) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
return Array.from(segmenter.segment(text), (segment) => segment.segment);
}
return Array.from(text);
};
const elements = useMemo(() => {
const currentText = texts[currentTextIndex];
if (splitBy === "characters") {
const words = currentText.split(" ");
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1,
}));
}
if (splitBy === "words") {
return currentText.split(" ").map((word, i, arr) => ({
characters: [word],
needsSpace: i !== arr.length - 1,
}));
}
if (splitBy === "lines") {
return currentText.split("\n").map((line, i, arr) => ({
characters: [line],
needsSpace: i !== arr.length - 1,
}));
}
// For a custom separator
return currentText.split(splitBy).map((part, i, arr) => ({
characters: [part],
needsSpace: i !== arr.length - 1,
}));
}, [texts, currentTextIndex, splitBy]);
const getStaggerDelay = useCallback(
(index, totalChars) => {
const total = totalChars;
if (staggerFrom === "first") return index * staggerDuration;
if (staggerFrom === "last") return (total - 1 - index) * staggerDuration;
if (staggerFrom === "center") {
const center = Math.floor(total / 2);
return Math.abs(center - index) * staggerDuration;
}
if (staggerFrom === "random") {
const randomIndex = Math.floor(Math.random() * total);
return Math.abs(randomIndex - index) * staggerDuration;
}
return Math.abs(staggerFrom - index) * staggerDuration;
},
[staggerFrom, staggerDuration]
);
const handleIndexChange = useCallback(
(newIndex) => {
setCurrentTextIndex(newIndex);
if (onNext) onNext(newIndex);
},
[onNext]
);
const next = useCallback(() => {
const nextIndex =
currentTextIndex === texts.length - 1
? loop
? 0
: currentTextIndex
: currentTextIndex + 1;
if (nextIndex !== currentTextIndex) {
handleIndexChange(nextIndex);
}
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
const previous = useCallback(() => {
const prevIndex =
currentTextIndex === 0
? loop
? texts.length - 1
: currentTextIndex
: currentTextIndex - 1;
if (prevIndex !== currentTextIndex) {
handleIndexChange(prevIndex);
}
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
const jumpTo = useCallback(
(index) => {
const validIndex = Math.max(0, Math.min(index, texts.length - 1));
if (validIndex !== currentTextIndex) {
handleIndexChange(validIndex);
}
},
[texts.length, currentTextIndex, handleIndexChange]
);
const reset = useCallback(() => {
if (currentTextIndex !== 0) {
handleIndexChange(0);
}
}, [currentTextIndex, handleIndexChange]);
useImperativeHandle(
ref,
() => ({
next,
previous,
jumpTo,
reset,
}),
[next, previous, jumpTo, reset]
);
useEffect(() => {
if (!auto) return;
const intervalId = setInterval(next, rotationInterval);
return () => clearInterval(intervalId);
}, [next, rotationInterval, auto]);
return (
<motion.span
className={cn(
"flex flex-wrap whitespace-pre-wrap relative",
mainClassName
)}
{...rest}
layout
transition={transition}
>
{/* Screen-reader only text */}
<span className="sr-only">{texts[currentTextIndex]}</span>
<AnimatePresence mode={animatePresenceMode} initial={animatePresenceInitial}>
<motion.div
key={currentTextIndex}
className={cn(
splitBy === "lines"
? "flex flex-col w-full"
: "flex flex-wrap whitespace-pre-wrap relative"
)}
layout
aria-hidden="true"
>
{elements.map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0);
return (
<span key={wordIndex} className={cn("inline-flex", splitLevelClassName)}>
{wordObj.characters.map((char, charIndex) => (
<motion.span
key={charIndex}
initial={initial}
animate={animate}
exit={exit}
transition={{
...transition,
delay: getStaggerDelay(
previousCharsCount + charIndex,
array.reduce((sum, word) => sum + word.characters.length, 0)
),
}}
className={cn("inline-block", elementLevelClassName)}
>
{char}
</motion.span>
))}
{wordObj.needsSpace && <span className="whitespace-pre"> </span>}
</span>
);
})}
</motion.div>
</AnimatePresence>
</motion.span>
);
});
RotatingText.displayName = "RotatingText";
export default RotatingText;

View File

@ -0,0 +1,41 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
const ShinyText = ({ text, disabled = false, speed = 5, className = '' }) => {
const animationDuration = `${speed}s`;
return (
<div
className={`text-[#b5b5b5a4] bg-clip-text inline-block ${disabled ? '' : 'animate-shine'} ${className}`}
style={{
backgroundImage: 'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
animationDuration: animationDuration,
}}
>
{text}
</div>
);
};
export default ShinyText;
// tailwind.config.js
// module.exports = {
// theme: {
// extend: {
// keyframes: {
// shine: {
// '0%': { 'background-position': '100%' },
// '100%': { 'background-position': '-100%' },
// },
// },
// animation: {
// shine: 'shine 5s linear infinite',
// },
// },
// },
// plugins: [],
// };

View File

@ -0,0 +1,144 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
const TrueFocus = ({
sentence = "True Focus",
manualMode = false,
blurAmount = 5,
borderColor = "green",
glowColor = "rgba(0, 255, 0, 0.6)",
animationDuration = 0.5,
pauseBetweenAnimations = 1,
}) => {
const words = sentence.split(" ");
const [currentIndex, setCurrentIndex] = useState(0);
const [lastActiveIndex, setLastActiveIndex] = useState(null);
const containerRef = useRef(null);
const wordRefs = useRef([]);
const [focusRect, setFocusRect] = useState({ x: 0, y: 0, width: 0, height: 0 });
useEffect(() => {
if (!manualMode) {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % words.length);
}, (animationDuration + pauseBetweenAnimations) * 1000);
return () => clearInterval(interval);
}
}, [manualMode, animationDuration, pauseBetweenAnimations, words.length]);
useEffect(() => {
if (currentIndex === null || currentIndex === -1) return;
if (!wordRefs.current[currentIndex] || !containerRef.current) return;
const parentRect = containerRef.current.getBoundingClientRect();
const activeRect = wordRefs.current[currentIndex].getBoundingClientRect();
setFocusRect({
x: activeRect.left - parentRect.left,
y: activeRect.top - parentRect.top,
width: activeRect.width,
height: activeRect.height,
});
}, [currentIndex, words.length]);
const handleMouseEnter = (index) => {
if (manualMode) {
setLastActiveIndex(index);
setCurrentIndex(index);
}
};
const handleMouseLeave = () => {
if (manualMode) {
setCurrentIndex(lastActiveIndex);
}
};
return (
<div
className="relative flex gap-4 justify-center items-center flex-wrap"
ref={containerRef}
>
{words.map((word, index) => {
const isActive = index === currentIndex;
return (
<span
key={index}
ref={(el) => (wordRefs.current[index] = el)}
className="relative text-[3rem] font-black cursor-pointer"
style={{
filter: manualMode
? isActive
? `blur(0px)`
: `blur(${blurAmount}px)`
: isActive
? `blur(0px)`
: `blur(${blurAmount}px)`,
"--border-color": borderColor,
"--glow-color": glowColor,
transition: `filter ${animationDuration}s ease`,
}}
onMouseEnter={() => handleMouseEnter(index)}
onMouseLeave={handleMouseLeave}
>
{word}
</span>
);
})}
<motion.div
className="absolute top-0 left-0 pointer-events-none box-border border-0"
animate={{
x: focusRect.x,
y: focusRect.y,
width: focusRect.width,
height: focusRect.height,
opacity: currentIndex >= 0 ? 1 : 0,
}}
transition={{
duration: animationDuration,
}}
style={{
"--border-color": borderColor,
"--glow-color": glowColor,
}}
>
<span
className="absolute w-4 h-4 border-[3px] rounded-[3px] top-[-10px] left-[-10px] border-r-0 border-b-0"
style={{
borderColor: "var(--border-color)",
filter: "drop-shadow(0 0 4px var(--border-color))",
}}
></span>
<span
className="absolute w-4 h-4 border-[3px] rounded-[3px] top-[-10px] right-[-10px] border-l-0 border-b-0"
style={{
borderColor: "var(--border-color)",
filter: "drop-shadow(0 0 4px var(--border-color))",
}}
></span>
<span
className="absolute w-4 h-4 border-[3px] rounded-[3px] bottom-[-10px] left-[-10px] border-r-0 border-t-0"
style={{
borderColor: "var(--border-color)",
filter: "drop-shadow(0 0 4px var(--border-color))",
}}
></span>
<span
className="absolute w-4 h-4 border-[3px] rounded-[3px] bottom-[-10px] right-[-10px] border-l-0 border-t-0"
style={{
borderColor: "var(--border-color)",
filter: "drop-shadow(0 0 4px var(--border-color))",
}}
></span>
</motion.div>
</div>
);
};
export default TrueFocus;

View File

@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { FaFire } from 'react-icons/fa';
function FlameRatingInput({ value, onChange }) {
const [hoverValue, setHoverValue] = useState(0);
const handleMouseEnter = (rating) => {
setHoverValue(rating);
};
const handleMouseLeave = () => {
setHoverValue(0);
};
const handleClick = (rating) => {
onChange(rating);
};
return (
<div className="flex items-center" onMouseLeave={handleMouseLeave}>
{[...Array(10)].map((_, index) => {
const rating = index + 1;
const isFilled = rating <= (hoverValue || value); // Use hoverValue if > 0, otherwise use actual value
return (
<FaFire
key={rating}
className={`flame-rating-icon w-5 h-5 ${isFilled ? 'filled' : ''}`}
onMouseEnter={() => handleMouseEnter(rating)}
onClick={() => handleClick(rating)}
/>
);
})}
</div>
);
}
export default FlameRatingInput;

View File

@ -0,0 +1,54 @@
import React from 'react';
import Marquee from 'react-fast-marquee';
import { Link } from 'react-router-dom';
import { FaFire } from 'react-icons/fa';
import DOMPurify from 'dompurify'; // Import DOMPurify
function LatestReviewsMarquee({ reviews }) {
if (!reviews || reviews.length === 0) {
return null;
}
return (
<div className="bg-campfire-charcoal py-4 shadow-inner">
<Marquee gradient={false} speed={40} pauseOnHover={true}>
{reviews.map(review => {
const user = review.expand?.user_id;
const media = review.expand?.media_id;
if (!user || !media || !user.username || !media.path || !media.title) {
console.warn(`Skipping review ${review.id} in marquee due to missing expanded user, media, or required fields.`);
return null;
}
// Sanitize and strip HTML from the review content for the marquee
const cleanContent = DOMPurify.sanitize(review.content, { ALLOWED_TAGS: [] }); // Allow no HTML tags
return (
<Link
key={review.id}
to={`/media/${media.path}`}
className="flex items-center mx-6 text-campfire-ash hover:text-campfire-light transition-colors"
>
<span className="font-semibold text-campfire-amber mr-2">
{user.username}:
</span>
<span className="mr-2 line-clamp-1 max-w-xs">
"{cleanContent.substring(0, 50)}{cleanContent.length > 50 ? '...' : ''}" {/* Use cleanContent */}
</span>
<span className="flex items-center text-campfire-amber">
<FaFire className="mr-1" size={14} />
{review.overall_rating !== undefined && review.overall_rating !== null ? parseFloat(review.overall_rating).toFixed(1) : 'N/A'} / 10
</span>
<span className="ml-2 text-sm text-campfire-ash/80">
на "{media.title}"
</span>
</Link>
);
})}
</Marquee>
</div>
);
}
export default LatestReviewsMarquee;

View File

@ -0,0 +1,127 @@
import React, { useState, useEffect } from 'react';
import { FaHeart, FaRegHeart } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { pb } from '../../services/pocketbaseService';
import { toast } from 'react-toastify';
const LikeButton = ({ reviewId, initialLikes = 0, onLikeChange, reviewOwnerId }) => {
const { user } = useAuth();
const [isLiked, setIsLiked] = useState(false);
const [likesCount, setLikesCount] = useState(initialLikes);
const [isLoading, setIsLoading] = useState(false);
const [likedUsers, setLikedUsers] = useState([]);
const [showTooltip, setShowTooltip] = useState(false);
useEffect(() => {
const checkLikeStatus = async () => {
if (!user) return;
try {
// Получаем рецензию и проверяем, есть ли пользователь в списке лайков
const review = await pb.collection('reviews').getOne(reviewId);
const likes = review.likes || [];
setIsLiked(likes.includes(user.id));
setLikesCount(likes.length);
// Если пользователь - владелец рецензии и есть лайки, получаем информацию о пользователях
if (user.id === reviewOwnerId && likes.length > 0) {
// Получаем информацию о пользователях, которые поставили лайки
const users = await Promise.all(
likes.map(userId =>
pb.collection('users').getOne(userId)
.catch(() => null) // Игнорируем ошибки для отдельных пользователей
)
);
// Фильтруем null значения и обновляем состояние
setLikedUsers(users.filter(Boolean));
}
} catch (err) {
console.error('Ошибка при проверке статуса лайка:', err);
setIsLiked(false);
}
};
checkLikeStatus();
}, [user, reviewId, reviewOwnerId]);
const handleLike = async () => {
if (!user) {
toast.error('Необходимо войти в систему');
return;
}
setIsLoading(true);
try {
// Получаем текущую рецензию
const review = await pb.collection('reviews').getOne(reviewId);
const currentLikes = review.likes || [];
let newLikes;
if (isLiked) {
// Удаляем лайк
newLikes = currentLikes.filter(id => id !== user.id);
} else {
// Добавляем лайк
newLikes = [...currentLikes, user.id];
}
// Обновляем рецензию с новым списком лайков
await pb.collection('reviews').update(reviewId, {
likes: newLikes
});
// Если пользователь - владелец рецензии, обновляем список лайкнувших
if (user.id === reviewOwnerId) {
const users = await Promise.all(
newLikes.map(userId =>
pb.collection('users').getOne(userId)
.catch(() => null)
)
);
setLikedUsers(users.filter(Boolean));
}
setIsLiked(!isLiked);
setLikesCount(newLikes.length);
if (onLikeChange) {
onLikeChange(!isLiked);
}
} catch (err) {
console.error('Ошибка при обработке лайка:', err);
toast.error('Не удалось обработать лайк');
} finally {
setIsLoading(false);
}
};
return (
<div className="relative group">
<button
onClick={handleLike}
disabled={isLoading}
className={`flex items-center space-x-1 ${
isLiked ? 'text-red-500' : 'text-campfire-light/70'
} hover:text-red-500 transition-colors`}
title={isLiked ? 'Убрать лайк' : 'Поставить лайк'}
>
{isLiked ? <FaHeart /> : <FaRegHeart />}
<span>{likesCount}</span>
</button>
{/* Показываем список лайкнувших только владельцу рецензии при наведении */}
{user?.id === reviewOwnerId && likesCount > 0 && (
<div className="absolute left-0 bottom-full mb-2 w-48 bg-campfire-darker rounded-lg shadow-lg p-2 z-10 border border-campfire-ash/20 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<div className="text-xs text-campfire-light/70 mb-1">Лайкнули:</div>
{likedUsers.map(user => (
<div key={user.id} className="text-sm text-campfire-light py-1">
{user.username}
</div>
))}
</div>
)}
</div>
);
};
export default LikeButton;

View File

@ -0,0 +1,141 @@
import React from 'react';
import {
Chart as ChartJS,
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
} from 'chart.js';
import { Radar } from 'react-chartjs-2';
ChartJS.register(
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
);
// Define colors based on your theme
const chartColors = {
amber: '#FF9D00', // Campfire Amber
dark: '#1A202C', // Campfire Dark
ash: '#A0AEC0', // Campfire Ash
light: '#F7FAFC', // Campfire Light
charcoal: '#2D3748', // Campfire Charcoal
};
function RatingChart({ ratings, labels, size = 'medium' }) {
// Ensure ratings and labels are valid objects
if (!ratings || typeof ratings !== 'object' || !labels || typeof labels !== 'object') {
return <div className="text-center text-campfire-ash">Нет данных для графика.</div>;
}
// Filter out ratings that are not numbers or are outside the 1-10 range
const validRatings = Object.entries(ratings)
.filter(([key, value]) => typeof value === 'number' && value >= 1 && value <= 10);
// If no valid ratings, don't render the chart
if (validRatings.length === 0) {
return <div className="text-center text-campfire-ash">Нет данных для графика.</div>;
}
// Prepare data for the chart
const chartLabels = validRatings.map(([key]) => labels[key] || key); // Use label if available, otherwise key
const chartDataValues = validRatings.map(([key, value]) => value);
const data = {
labels: chartLabels,
datasets: [
{
label: 'Оценка', // Or 'Средняя оценка' depending on context
data: chartDataValues,
backgroundColor: `${chartColors.amber}40`, // Amber with transparency
borderColor: chartColors.amber,
pointBackgroundColor: chartColors.amber,
pointBorderColor: chartColors.light,
pointHoverBackgroundColor: chartColors.light,
pointHoverBorderColor: chartColors.amber,
borderWidth: 2,
fill: true,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: size === 'small' ? false : true, // Allow small charts to not maintain aspect ratio
aspectRatio: size === 'small' ? 1 : (size === 'medium' ? 1.5 : 2), // Adjust aspect ratio based on size
scales: {
r: {
angleLines: {
display: true,
color: chartColors.ash + '40', // Ash with transparency
},
grid: {
color: chartColors.ash + '40', // Ash with transparency
},
pointLabels: {
color: chartColors.light, // Light text for labels
font: {
size: size === 'small' ? 8 : (size === 'medium' ? 10 : 12), // Adjust font size
}
},
suggestedMin: 0, // Start from 0
suggestedMax: 10, // Max rating is 10
ticks: {
stepSize: 2, // Steps of 2
color: chartColors.ash, // Ash color for ticks
backdropColor: chartColors.charcoal, // Background color for tick labels
font: {
size: size === 'small' ? 8 : (size === 'medium' ? 10 : 12), // Adjust font size
}
},
},
},
plugins: {
legend: {
display: false, // Hide legend
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.raw !== null) {
label += context.raw.toFixed(1); // Show rating with one decimal place
}
return label;
}
}
}
},
elements: {
line: {
tension: 0.1 // Add some tension for smoother lines
}
}
};
// Adjust container size based on the 'size' prop
const containerClasses = size === 'small'
? 'w-32 h-32' // Smaller size for individual review items
: size === 'medium'
? 'w-48 h-48 md:w-64 md:h-64' // Medium size for form preview
: 'w-64 h-64 md:w-80 md:h-80 lg:w-96 lg:h-96'; // Large size for media page aggregate chart
return (
<div className={`relative mx-auto ${containerClasses}`}>
<Radar data={data} options={options} />
</div>
);
}
export default RatingChart;

View File

@ -0,0 +1,137 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { FaThumbsUp, FaComment, FaShare } from 'react-icons/fa';
import RatingChart from './RatingChart';
// Accept characteristics prop
function ReviewCard({ review, isDetailed = false, characteristics }) {
const [isExpanded, setIsExpanded] = useState(false);
const {
id,
users, // Changed from user to users
content,
ratings,
likes,
comments,
created_at,
spoiler
} = review;
// Use users object and rename for clarity
const userProfile = users;
// Format date
const formattedDate = new Date(created_at).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
// Calculate overall rating
const overallRating = Object.values(ratings).reduce((sum, rating) => sum + rating, 0) / Object.keys(ratings).length;
// Handle content length
const isLongContent = content.length > 300;
const displayContent = isLongContent && !isExpanded ? `${content.substring(0, 280)}...` : content;
return (
<div className={`bg-campfire-charcoal rounded-lg shadow-lg p-6 mb-6 ${isDetailed ? 'border border-campfire-ash' : ''}`}>
{/* Review Header */}
<div className="flex justify-between items-start mb-4">
<div className="flex items-center">
<Link to={`/profile/${userProfile?.username}`}> {/* Use userProfile?.id */}
<img
src={userProfile?.profile_picture || 'https://questhowth.ie/wp-content/uploads/2018/04/user-placeholder.png'} // Use userProfile?.profile_picture
alt={userProfile?.username} // Use userProfile?.username
className="w-10 h-10 rounded-full object-cover mr-3"
/>
</Link>
<div>
<Link to={`/profile/${userProfile?.username}`} className="font-medium text-campfire-light hover:text-campfire-amber"> {/* Use userProfile?.id */}
{userProfile?.username} {/* Use userProfile?.username */}
</Link>
{userProfile?.is_critic && ( // Use userProfile?.is_critic
<span className="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-campfire-amber text-campfire-dark rounded-full">
Critic
</span>
)}
<div className="text-sm text-campfire-ash">{formattedDate}</div>
</div>
</div>
<div className="flex items-center">
<span className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-campfire-amber text-campfire-dark font-bold">
{overallRating.toFixed(1)}
</span>
</div>
</div>
{/* Review Content */}
{/* Ensure grid is applied on md screens when detailed */}
{/* Changed grid-cols-3 to grid-cols-2 for 2 columns */}
<div className={`grid grid-cols-1 gap-8 ${isDetailed ? 'md:grid md:grid-cols-2' : ''}`}>
{/* Text Content */}
{/* Changed col-span-2 to col-span-1 for 2 columns */}
{/* Removed overflow-hidden, kept break-words */}
<div className={`${isDetailed ? 'md:col-span-1 break-words' : ''}`}>
{/* Spoiler Warning */}
{spoiler && (
<div className="bg-status-warning bg-opacity-20 text-status-warning p-3 rounded-md mb-4">
<span className="font-bold">Spoiler Warning:</span> This review contains spoilers.
</div>
)}
{/* Review Text */}
<p className="text-campfire-light mb-4">
{displayContent}
</p>
{/* Expand/Collapse for long content */}
{isLongContent && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-campfire-amber hover:text-campfire-ember mb-4 text-sm font-medium"
>
{isExpanded ? 'Show Less' : 'Read More'}
</button>
)}
</div>
{/* Rating Chart */}
{/* Ensure chart takes 1 column on md screens when detailed */}
{/* Added flex, justify-center, items-center to center the chart */}
<div className={`${isDetailed ? 'md:col-span-1 flex justify-center items-center' : 'hidden md:flex md:justify-center md:items-center'}`}>
<RatingChart
ratings={ratings}
// Pass characteristics as labels to RatingChart
labels={characteristics}
size={isDetailed ? 'big' : 'medium'} // Reverted size to 'big' for detailed view
showLegend={isDetailed}
isDetailed={isDetailed} // Ensure prop is passed
/>
</div>
</div>
{/* Review Footer */}
<div className="flex items-center justify-between mt-6 pt-4 border-t border-campfire-dark">
<div className="flex space-x-6">
<button className="flex items-center text-campfire-ash hover:text-campfire-amber">
<FaThumbsUp className="mr-2" />
<span>{likes}</span>
</button>
<button className="flex items-center text-campfire-ash hover:text-campfire-amber">
{/* Safely access comments length */}
<FaComment className="mr-2" />
<span>{comments?.length || 0}</span>
</button>
</div>
<button className="flex items-center text-campfire-ash hover:text-campfire-amber">
<FaShare className="mr-2" />
<span>Share</span>
</button>
</div>
</div>
);
}
export default ReviewCard;

View File

@ -0,0 +1,522 @@
import { useState, useEffect } from 'react';
import { FaFire, FaEdit, FaTrashAlt } from 'react-icons/fa'; // Changed FaStar to FaFire
import RatingChart from './RatingChart';
import FlameRatingInput from './FlameRatingInput'; // Import the new component
import ReactQuill from 'react-quill'; // Import ReactQuill
import 'react-quill/dist/quill.snow.css'; // Import Quill styles (snow theme)
// Default characteristics if none are provided (fallback)
const defaultCharacteristics = {
overall: 'Общая оценка' // Ensure a default 'overall' characteristic
};
// Mapping for watched/completed status values
const watchedStatusLabels = {
not_watched: 'Не просмотрено',
watched: 'Просмотрено',
};
const completedStatusLabels = {
not_completed: 'Не пройдено',
completed: 'Пройдено',
};
// ReviewForm now expects characteristics prop to be the media's characteristics { [key]: label }
// Also expects mediaType, progressType, seasons, and selectedSeasonId
function ReviewForm({ mediaId, seasonId, mediaType, progressType, onSubmit, onEdit, onDelete, characteristics = defaultCharacteristics, existingReview, seasons = [], selectedSeasonId }) {
// Initial state for ratings, now storing just the number { [key]: number }
// Initialize with a default value (e.g., 5) for each characteristic provided
const initialRatings = Object.keys(characteristics).reduce((acc, key) => {
acc[key] = 5; // Default rating of 5
return acc;
}, {});
const [ratings, setRatings] = useState(initialRatings);
// Use empty string for Quill content state initially
const [content, setContent] = useState('');
const [hasSpoilers, setHasSpoilers] = useState(false);
// Use 'progress' state instead of 'status'
const [progress, setProgress] = useState(''); // State for progress (text field)
const [isSubmitting, setIsSubmitting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
// New state for selected season in the form (only relevant if media supports seasons)
// Initialize with the selectedSeasonId passed from the parent
const [formSeasonId, setFormSeasonId] = useState(selectedSeasonId);
// Determine the correct progress options/label based *only* on progressType
const getProgressOptions = () => {
if (progressType === 'watched') {
return watchedStatusLabels;
} else if (progressType === 'completed') {
return completedStatusLabels;
}
// If progressType is 'hours' or unknown, use text input
return null;
};
const progressOptions = getProgressOptions();
const isProgressSelect = progressOptions !== null; // Determine if we should show a select/segmented control
// Determine if the media type supports seasons
const supportsSeasons = mediaType === 'tv' || mediaType === 'anime';
// Reset ratings, content, progress, and formSeasonId when characteristics, existingReview, progressType, or selectedSeasonId change
useEffect(() => {
console.log('ReviewForm useEffect: existingReview changed', existingReview); // LOG
console.log('ReviewForm useEffect: selectedSeasonId changed', selectedSeasonId); // LOG
// Ensure characteristics is an object before processing
const validCharacteristics = characteristics && typeof characteristics === 'object' ? characteristics : defaultCharacteristics;
if (existingReview) {
// If editing, pre-fill form with existing review data
// Expect existingReview.ratings to be { [key]: number }
const populatedRatings = Object.keys(validCharacteristics).reduce((acc, key) => {
// Use existing rating if it's a number, otherwise default to 5
acc[key] = typeof existingReview.ratings?.[key] === 'number' ? existingReview.ratings[key] : 5;
return acc;
}, {});
setRatings(populatedRatings);
// Set content from existing review (assuming it's HTML)
setContent(existingReview.content || '');
setHasSpoilers(existingReview.has_spoilers ?? false);
// Initialize progress from existing review
setProgress(existingReview.progress || '');
// Initialize formSeasonId from existing review's season_id
setFormSeasonId(existingReview.season_id || null); // Use null for overall review
console.log('ReviewForm useEffect: Setting isEditing to false (existing review)'); // LOG
setIsEditing(false); // Start in view mode
} else {
// If creating, reset form
const newInitialRatings = Object.keys(validCharacteristics).reduce((acc, key) => {
acc[key] = 5; // Default rating of 5
return acc;
}, {});
setRatings(newInitialRatings);
setContent(''); // Reset content to empty string for Quill
setHasSpoilers(false);
// Reset progress based on the type of input expected
if (isProgressSelect) {
// Set default for select based on options (e.g., 'completed' or 'not_watched')
// Default to the first option key, which should be the 'not_' status
setProgress(Object.keys(progressOptions)[0] || '');
} else {
setProgress(''); // Default to empty string for text input (hours)
}
// Reset formSeasonId to the currently selected season on the page
setFormSeasonId(selectedSeasonId);
console.log('ReviewForm useEffect: Resetting form, setting isEditing to false (no existing review)'); // LOG
setIsEditing(false);
}
}, [characteristics, existingReview, progressType, isProgressSelect, selectedSeasonId, seasons]); // Depend on selectedSeasonId and seasons too
// Add a log to see when isEditing state changes
useEffect(() => {
console.log('ReviewForm: isEditing state changed to', isEditing); // LOG
}, [isEditing]);
const handleRatingChange = (category, value) => {
setRatings(prev => ({
...prev,
[category]: value // Store the number directly from FlameRatingInput
}));
};
const handleProgressChange = (value) => {
setProgress(value);
};
const handleFormSeasonChange = (e) => {
// Convert the value to null if it's the "Общее" option
const value = e.target.value === '' ? null : e.target.value;
setFormSeasonId(value);
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
// Validate that all characteristics have a rating (since N/A is removed)
const allRatingsValid = Object.keys(characteristics).every(key =>
typeof ratings[key] === 'number' && ratings[key] >= 1 && ratings[key] <= 10
);
// Validate that progress is filled/selected
const isProgressValid = isProgressSelect ? Object.keys(progressOptions).includes(progress) : progress.trim() !== '';
// Validate that a season is selected if media supports seasons and it's not the overall review
// This validation is now relaxed: if supportsSeasons, formSeasonId can be null OR a valid season ID
const isSeasonSelectedValid = supportsSeasons ? (formSeasonId === null || seasons.some(s => s.id === formSeasonId)) : true;
// Validate that content is not empty (check Quill's internal state or HTML string)
// Quill's empty state is typically '<p><br></p>' or just ''
const isContentValid = content.trim() !== '' && content !== '<p><br></p>';
if (!allRatingsValid || !isProgressValid || !isSeasonSelectedValid || !isContentValid) {
console.error("Validation failed: Not all characteristics have a valid rating, progress is invalid/empty, season selection is invalid, or content is empty.");
// Optionally set an error message here if needed, though button is disabled
setIsSubmitting(false);
return;
}
const reviewData = {
media_id: mediaId,
season_id: formSeasonId, // Use the season selected in the form (can be null)
media_type: mediaType,
content, // Use content from Quill editor state (HTML string)
ratings, // Store the ratings object { [key]: number }
has_spoilers: hasSpoilers,
progress, // Include progress (text field)
};
if (existingReview && isEditing) {
// If editing an existing review
await onEdit(existingReview.id, reviewData);
console.log('ReviewForm: Edit submitted, setting isEditing to false'); // LOG
setIsEditing(false); // Exit editing mode after submit
} else {
// If creating a new review
await onSubmit(reviewData);
// Form reset is handled by the useEffect when existingReview becomes null or changes
}
} catch (error) {
console.error('Error submitting review:', error);
// Optionally set an error state to display to the user
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (window.confirm('Вы уверены, что хотите удалить эту рецензию?')) {
setIsSubmitting(true);
try {
await onDelete(existingReview.id, mediaId); // deleteReview only needs reviewId, mediaId is optional
// Form reset is handled by the useEffect when existingReview becomes null
} catch (error) {
console.error('Error deleting review:', error);
// Optionally set an error state
} finally {
setIsSubmitting(false);
}
}
};
// Determine if the form is valid for submission
// Content must not be empty, ALL characteristics must have a valid rating (1-10), and progress must be filled/selected
const isFormValid = content.trim() !== '' && content !== '<p><br></p>' && // Check content validity
Object.keys(characteristics).length > 0 && // Ensure characteristics are loaded
Object.keys(characteristics).every(key =>
typeof ratings[key] === 'number' && ratings[key] >= 1 && ratings[key] <= 10
) &&
(isProgressSelect ? Object.keys(progressOptions).includes(progress) : progress.trim() !== '') && // Check progress validity based on input type
(supportsSeasons ? (formSeasonId === null || seasons.some(s => s.id === formSeasonId)) : true); // Check season selection validity
// If an existing review is present and not in editing mode, show review details instead of the form
if (existingReview && !isEditing) {
return (
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6 text-center border border-campfire-ash/20">
<p className="text-campfire-light mb-4">Вы уже написали рецензию на это произведение.</p>
<div className="flex justify-center space-x-4">
<button
onClick={() => {
console.log('ReviewForm: Edit button clicked, setting isEditing to true'); // LOG
setIsEditing(true);
}}
className="btn-secondary flex items-center"
disabled={isSubmitting}
>
<FaEdit className="mr-2" /> Редактировать
</button>
{/* Fixed delete button text color */}
<button
onClick={handleDelete}
className="btn-danger flex items-center text-white" // Added text-white class
disabled={isSubmitting}
>
<FaTrashAlt className="mr-2" /> Удалить
</button>
</div>
</div>
);
}
// Quill modules - define toolbar options
const modules = {
toolbar: [
[{ 'header': [1, 2, false] }],
['bold', 'italic', 'underline', 'strike', 'blockquote'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['link'],
['clean']
],
};
const formats = [
'header',
'bold', 'italic', 'underline', 'strike', 'blockquote',
'list', 'bullet',
'link'
];
return (
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6 border border-campfire-ash/20">
<h2 className="text-xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4">
{existingReview ? 'Редактировать рецензию' : 'Написать рецензию'}
</h2>
{/* Add a log to see if this form section is being rendered */}
{existingReview && isEditing && console.log('ReviewForm: Rendering Edit Form')}
{!existingReview && console.log('ReviewForm: Rendering Create Form')}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-6">
<div className="md:col-span-2">
{/* Season Selection (if media supports seasons) */}
{supportsSeasons && seasons.length > 0 && (
<div className="mb-6">
<label htmlFor="season-select" className="block mb-2 text-campfire-light">
Сезон <span className="text-red-500">*</span>
</label>
<select
id="season-select"
value={formSeasonId === null ? '' : formSeasonId} // Use empty string for null to match option value
onChange={handleFormSeasonChange}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
required={supportsSeasons && formSeasonId === undefined} // Require if seasons are supported and no season is selected yet
>
<option value="">Общее</option> {/* Option for overall review */}
{seasons.map(season => (
<option key={season.id} value={season.id}>
Сезон {season.season_number} {season.title ? ` - ${season.title}` : ''}
</option>
))}
</select>
</div>
)}
{/* Progress Input - Conditional based on progressType */}
<div className="mb-6">
<label className="block mb-2 text-campfire-light">
{progressType === 'hours'
? 'Часов проведено'
: progressType === 'watched'
? 'Статус просмотра'
: progressType === 'completed'
? 'Статус прохождения'
: 'Прогресс' // Fallback label
} <span className="text-red-500">*</span>
</label>
{isProgressSelect ? (
// Segmented control for watched/completed status
<div className="flex rounded-md overflow-hidden border border-campfire-ash/30">
{Object.entries(progressOptions).map(([key, label]) => (
<button
key={key}
type="button" // Use type="button" to prevent form submission
onClick={() => handleProgressChange(key)}
className={`flex-1 text-center py-2 text-sm font-medium transition-colors duration-200
${progress === key
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-dark text-campfire-ash hover:bg-campfire-ash/20'
}`}
>
{label}
</button>
))}
</div>
) : (
// Text input for hours (for games with progressType 'hours') or fallback
<input
type={progressType === 'hours' ? 'number' : 'text'} // Use number type for hours, text for others
min={progressType === 'hours' ? "0" : undefined} // Hours cannot be negative
placeholder={progressType === 'hours' ? "Введите количество часов..." : "Введите прогресс..."}
value={progress}
onChange={(e) => handleProgressChange(e.target.value)}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
required
/>
)}
{/* Add a hidden required input to satisfy HTML5 validation */}
{/* This hidden input is a fallback; actual validation is in isFormValid */}
{/* Removed the hidden input as isFormValid handles validation */}
</div>
{/* Rating Inputs - Using FlameRatingInput */}
<div className="space-y-8 mb-6"> {/* Increased spacing */}
{/* Iterate over characteristics provided by the media */}
{Object.entries(characteristics).map(([key, label]) => (
<div key={key} className="space-y-2">
<div className="flex justify-between items-center mb-1">
<label htmlFor={`rating-${key}`} className="block text-sm font-medium text-campfire-light">{label}</label>
{/* Display value - Changed FaStar to FaFire */}
<span className="flex items-center text-campfire-amber font-bold text-lg"> {/* Made value larger/bolder */}
<FaFire className="mr-1 text-base" /> {/* Adjusted icon size */}
{ratings[key] !== undefined ? ratings[key] : 5}
</span>
</div>
{/* Flame Rating Input Component */}
<FlameRatingInput
value={ratings[key] !== undefined ? ratings[key] : 5}
onChange={(value) => handleRatingChange(key, value)}
/>
</div>
))}
</div>
{/* Review Text - Using ReactQuill */}
<div className="mb-6">
<label htmlFor="review-content" className="block mb-2 text-campfire-light text-sm font-medium">
Ваша рецензия <span className="text-red-500">*</span>
</label>
<ReactQuill
theme="snow" // Use the snow theme
value={content}
onChange={setContent}
modules={modules}
formats={formats}
placeholder="Поделитесь своими мыслями об этом произведении..."
className="bg-campfire-dark text-campfire-light rounded-md border border-campfire-ash/30 quill-custom" // Added custom class
/>
{/* Add custom styles for Quill */}
<style>{`
.quill-custom .ql-toolbar {
background: #3a332d; /* campfire-charcoal */
border-top-left-radius: 0.375rem; /* rounded-md */
border-top-right-radius: 0.375rem; /* rounded-md */
border-color: #5a524a; /* campfire-ash/30 */
}
.quill-custom .ql-container {
border-bottom-left-radius: 0.375rem; /* rounded-md */
border-bottom-right-radius: 0.375rem; /* rounded-md */
border-color: #5a524a; /* campfire-ash/30 */
}
.quill-custom .ql-editor {
min-height: 150px; /* Adjust height as needed */
color: #f0e7db; /* campfire-light */
}
.quill-custom .ql-editor.ql-blank::before {
color: #a09a93; /* campfire-ash */
font-style: normal; /* Remove italic */
}
/* Style for toolbar buttons */
.quill-custom .ql-toolbar button {
color: #f0e7db; /* campfire-light */
}
.quill-custom .ql-toolbar button:hover {
color: #f59e0b; /* campfire-amber */
}
.quill-custom .ql-toolbar .ql-active {
color: #f59e0b; /* campfire-amber */
}
/* Style for dropdowns */
.quill-custom .ql-toolbar .ql-picker {
color: #f0e7db; /* campfire-light */
}
.quill-custom .ql-toolbar .ql-picker:hover {
color: #f59e0b; /* campfire-amber */
}
.quill-custom .ql-toolbar .ql-picker-label {
color: #f0e7db; /* campfire-light */
}
.quill-custom .ql-toolbar .ql-picker-label:hover {
color: #f59e0b; /* campfire-amber */
}
.quill-custom .ql-toolbar .ql-picker-label.ql-active {
color: #f59e0b; /* campfire-amber */
}
.quill-custom .ql-toolbar .ql-picker-item:hover {
color: #f59e0b; /* campfire-amber */
}
.quill-custom .ql-toolbar .ql-picker-item.ql-selected {
color: #f59e0b; /* campfire-amber */
}
.quill-custom .ql-tooltip {
background-color: #3a332d; /* campfire-charcoal */
color: #f0e7db; /* campfire-light */
border-color: #5a524a; /* campfire-ash/30 */
}
.quill-custom .ql-tooltip input[type=text] {
background-color: #2a2623; /* campfire-dark */
color: #f0e7db; /* campfire-light */
border-color: #5a524a; /* campfire-ash/30 */
}
`}</style>
</div>
{/* Spoiler Checkbox */}
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="spoiler-check"
checked={hasSpoilers}
onChange={(e) => setHasSpoilers(e.target.checked)}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2 cursor-pointer"
/>
<label htmlFor="spoiler-check" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer">
Эта рецензия содержит спойлеры
</label>
</div>
</div>
{/* Rating Chart Preview */}
<div className="md:col-span-1">
<p className="text-center text-campfire-light mb-4 font-semibold">Предварительный просмотр вашей оценки</p>
<RatingChart
ratings={Object.entries(ratings).reduce((acc, [key, value]) => {
if (characteristics.hasOwnProperty(key) && typeof value === 'number' && value >= 1 && value <= 10) {
acc[key] = value;
}
return acc;
}, {})}
labels={characteristics} // Pass the characteristics labels
size="medium"
/>
</div>
</div>
{/* Submit Button */}
<div className="flex justify-end mt-6"> {/* Added mt-6 for spacing */}
<button
type="submit"
disabled={isSubmitting || !isFormValid}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isSubmitting ? (existingReview ? 'Сохранение...' : 'Отправка...') : (existingReview ? 'Сохранить изменения' : 'Отправить рецензию')}
</button>
</div>
{/* Corrected error message condition */}
{!isFormValid && (content.trim() === '' || content === '<p><br></p>' || !(isProgressSelect ? Object.keys(progressOptions).includes(progress) : progress.trim() !== '') || Object.keys(characteristics).some(key => typeof ratings[key] !== 'number' || ratings[key] < 1 || ratings[key] > 10) || (supportsSeasons && formSeasonId === undefined)) && (
<p className="text-status-error text-sm mt-2 text-right">
Пожалуйста, заполните все обязательные поля (рецензия, прогресс, все оценки от 1 до 10{supportsSeasons ? ', сезон' : ''}).
</p>
)}
{Object.keys(characteristics).length === 0 && (
<p className="text-status-error text-sm mt-2 text-right">
Характеристики для этого произведения не загружены.
</p>
)}
</form>
</div>
);
}
export default ReviewForm;

View File

@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { FaFire, FaRegCommentDots, FaEye, FaClock, FaCheckCircle, FaGamepad } from 'react-icons/fa';
import { getFileUrl } from '../../services/pocketbaseService';
import DOMPurify from 'dompurify';
import LikeButton from './LikeButton';
// Mapping for watched/completed status values
const watchedStatusLabels = {
not_watched: 'Не просмотрено',
watched: 'Просмотрено',
};
const completedStatusLabels = {
not_completed: 'Не пройдено',
completed: 'Пройдено',
};
// ReviewItem now expects review, media, season, reviewCharacteristics, isProfilePage, and isSmallCard props
function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfilePage = false, isSmallCard = false }) {
const [showFullReview, setShowFullReview] = useState(false);
const [showSpoilers, setShowSpoilers] = useState(false);
const reviewMedia = media || review.expand?.media_id;
const reviewSeason = season || review.expand?.season_id;
const reviewUser = review.expand?.user_id;
const characteristics = reviewMedia?.characteristics && typeof reviewMedia.characteristics === 'object'
? reviewMedia.characteristics
: reviewCharacteristics;
const reviewTitle = reviewMedia
? `${reviewMedia.title}${reviewSeason ? ` - Сезон ${reviewSeason.season_number}${reviewSeason.title ? `: ${reviewSeason.title}` : ''}` : ''}`
: 'Неизвестное произведение';
const reviewLink = reviewMedia ? `/media/${reviewMedia.path}` : '#';
// Sanitize HTML content from the rich text editor
const sanitizedContent = DOMPurify.sanitize(review.content);
// Determine progress display based on media's progress_type
const renderProgress = () => {
if (!reviewMedia || !reviewMedia.progress_type || review.progress === undefined || review.progress === null) {
return null;
}
const progressType = reviewMedia.progress_type;
const progressValue = review.progress;
if (progressType === 'hours') {
return (
<span className="flex items-center text-campfire-ash text-sm">
<FaClock className="mr-1 text-base" /> {progressValue} часов
</span>
);
} else if (progressType === 'watched') {
const label = watchedStatusLabels[progressValue] || progressValue;
return (
<span className="flex items-center text-campfire-ash text-sm">
<FaEye className="mr-1 text-base" /> {label}
</span>
);
} else if (progressType === 'completed') {
const label = completedStatusLabels[progressValue] || progressValue;
return (
<span className="flex items-center text-campfire-ash text-sm">
<FaCheckCircle className="mr-1 text-base" /> {label}
</span>
);
}
return (
<span className="flex items-center text-campfire-ash text-sm">
Прогресс: {progressValue}
</span>
);
};
// Render as a small card for the "All Reviews" section on the profile page
if (isSmallCard) {
return (
<Link to={reviewLink} className="bg-campfire-charcoal rounded-lg shadow-md p-4 border border-campfire-ash/20 flex flex-col hover:border-campfire-amber transition-colors duration-200 relative overflow-hidden"> {/* Added relative and overflow-hidden */}
{/* Small Poster in corner - Only show on profile page small cards */}
{isProfilePage && reviewMedia?.poster && (
<div className="absolute top-0 right-0 w-16 h-24 overflow-hidden rounded-tr-lg rounded-bl-lg border-b border-l border-campfire-ash/30"> {/* Adjusted size and positioning */}
<img
src={getFileUrl(reviewMedia, 'poster')}
alt={`Постер ${reviewMedia.title}`}
className="w-full h-full object-cover" // Ensure image covers the container
/>
</div>
)}
{/* User Avatar and Title */}
<div className={`flex items-center mb-3 ${isProfilePage && reviewMedia?.poster ? 'pr-20' : ''}`}> {/* Added right padding conditionally */}
{reviewUser && (
<img
src={getFileUrl(reviewUser, 'profile_picture') || 'https://images.pexels.com/photos/1704488/pexels-photo-1704488.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'}
alt={reviewUser.username}
className="w-8 h-8 rounded-full object-cover mr-3 border border-campfire-ash/30"
/>
)}
<h3 className="text-sm font-bold text-campfire-amber leading-tight line-clamp-2 flex-grow">
{reviewTitle}
</h3>
</div>
{/* Overall Rating */}
<div className="flex items-center text-campfire-amber font-bold text-lg mb-3">
<FaFire className="mr-1 text-base" />
{review.overall_rating !== null && review.overall_rating !== undefined ? parseFloat(review.overall_rating).toFixed(1) : 'N/A'} / 10
</div>
{/* Progress Display */}
{renderProgress() && (
<div className="text-campfire-ash text-xs mb-3">
{renderProgress()}
</div>
)}
{/* Snippet of Review Content */}
<div className="text-campfire-ash text-xs leading-relaxed line-clamp-3 mb-3">
{/* Strip HTML for small card snippet */}
{sanitizedContent.replace(/<[^>]*>?/gm, '').substring(0, 150)}{sanitizedContent.replace(/<[^>]*>?/gm, '').length > 150 ? '...' : ''}
</div>
{/* Date */}
<div className="text-campfire-ash text-xs mt-auto pt-2 border-t border-campfire-ash/20">
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
</Link>
);
}
// Default rendering for larger cards (Showcase, Media Page)
return (
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6 border border-campfire-ash/20 flex flex-col">
{/* Media Poster - Only show on profile page large cards (Showcase) */}
{isProfilePage && reviewMedia?.poster && (
<Link to={reviewLink} className="block mb-4 self-center">
<img
src={getFileUrl(reviewMedia, 'poster')}
alt={`Постер ${reviewMedia.title}`}
className="w-32 h-auto object-cover rounded-md"
/>
</Link>
)}
{/* Header: User, Media Title, Overall Rating */}
<div className="flex items-center justify-between mb-4 border-b border-campfire-ash/20 pb-4">
<div className="flex items-center">
{/* User Avatar */}
{reviewUser && (
<Link to={`/profile/${reviewUser.username}`} className="flex-shrink-0 mr-4">
<img
src={getFileUrl(reviewUser, 'profile_picture') || 'https://images.pexels.com/photos/1704488/pexels-photo-1704488.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'}
alt={reviewUser.username}
className="w-10 h-10 rounded-full object-cover border border-campfire-ash/30"
/>
</Link>
)}
<div>
{/* User Link */}
{reviewUser && (
<Link to={`/profile/${reviewUser.username}`} className="text-campfire-light font-semibold hover:underline text-sm">
{reviewUser.username}
</Link>
)}
{/* Media Title Link */}
<h3 className="text-lg font-bold text-campfire-amber leading-tight">
<Link to={reviewLink} className="hover:underline">
{reviewTitle}
</Link>
</h3>
</div>
</div>
{/* Overall Rating */}
<div className="flex items-center text-campfire-amber font-bold text-xl flex-shrink-0">
<FaFire className="mr-1 text-lg" />
{review.overall_rating !== null && review.overall_rating !== undefined ? parseFloat(review.overall_rating).toFixed(1) : 'N/A'} / 10
</div>
</div>
{/* Characteristics Ratings */}
{Object.keys(characteristics).length > 0 && (
<div className="mb-4">
<p className="text-campfire-light text-sm font-semibold mb-2">Оценки по характеристикам:</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-campfire-ash text-sm">
{Object.entries(characteristics).map(([key, label]) => {
const ratingValue = review.ratings?.[key];
if (typeof ratingValue === 'number' && ratingValue >= 1 && ratingValue <= 10) {
return (
<div key={key} className="flex justify-between items-center">
<span>{label}:</span>
<span className="flex items-center text-campfire-amber font-bold">
<FaFire className="mr-1 text-xs" /> {ratingValue}
</span>
</div>
);
}
return null;
})}
</div>
</div>
)}
{/* Review Content and Footer (Flex container) */}
<div className="flex flex-col flex-grow">
{/* Review Content */}
<div className={`text-campfire-ash leading-relaxed mb-4 ${!showFullReview ? 'line-clamp-4' : ''}`}>
{review.has_spoilers && !showSpoilers ? (
<div className="bg-status-warning/10 border border-status-warning/20 text-status-warning p-4 rounded-md">
<p className="font-semibold mb-2">Внимание: Эта рецензия содержит спойлеры!</p>
<button
onClick={() => setShowSpoilers(true)}
className="text-status-warning hover:underline text-sm"
>
Показать спойлеры
</button>
</div>
) : (
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
)}
</div>
{/* Read More Button */}
{review.content && review.content.length > 300 && (
<button
onClick={() => setShowFullReview(!showFullReview)}
className="text-campfire-amber hover:underline text-sm self-start mb-4"
>
{showFullReview ? 'Свернуть' : 'Читать далее'}
</button>
)}
{/* Footer: Date, Comment Count (Placeholder) and Progress */}
<div className="flex items-end justify-between text-campfire-ash text-xs mt-auto pt-4 border-t border-campfire-ash/20">
<div className="flex items-center space-x-4">
<span>{new Date(review.created).toLocaleDateString()}</span>
{renderProgress()}
</div>
<LikeButton
reviewId={review.id}
initialLikes={review.like_count || 0}
reviewOwnerId={review.user_id}
/>
</div>
</div>
</div>
);
}
export default ReviewItem;

View File

@ -0,0 +1,31 @@
import React from 'react';
import ReviewItem from './ReviewItem';
// ReviewList now expects reviews and optionally reviewCharacteristics and isProfilePage
function ReviewList({ reviews, reviewCharacteristics = {}, isProfilePage = false }) {
return (
<div className="mt-8 space-y-6">
{reviews.length === 0 ? (
<div className="text-center py-8 text-campfire-light">
Рецензий пока нет. Будьте первым!
</div>
) : (
reviews.map(review => (
<ReviewItem
key={review.id}
review={review}
// Pass expanded media and season data from the review's expand field
media={review.expand?.media_id}
season={review.expand?.season_id}
// Pass characteristics from the expanded media (if available)
// ReviewItem will use this or fallback to review.expand.media_id.characteristics
reviewCharacteristics={review.expand?.media_id?.characteristics || reviewCharacteristics}
isProfilePage={isProfilePage} // Pass down the prop
/>
))
)}
</div>
);
}
export default ReviewList;

View File

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { createSuggestion } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
const SuggestCardWidget = ({ onSuccess }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
category: 'movie',
link: ''
});
const { user } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
if (!user) {
toast.error('Необходимо войти в систему');
return;
}
try {
await createSuggestion({
...formData,
status: 'pending',
user: user.id
});
toast.success('Предложение успешно отправлено!');
setFormData({
title: '',
description: '',
category: 'movie',
link: ''
});
if (onSuccess) {
onSuccess();
}
} catch (error) {
toast.error('Ошибка при отправке предложения');
console.error('Error submitting suggestion:', error);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-campfire-gray mb-2">Название</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
required
className="w-full bg-campfire-charcoal border border-campfire-gray rounded px-3 py-2 text-campfire-light focus:outline-none focus:border-campfire-secondary"
/>
</div>
<div>
<label className="block text-campfire-gray mb-2">Описание</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
required
rows="3"
className="w-full bg-campfire-charcoal border border-campfire-gray rounded px-3 py-2 text-campfire-light focus:outline-none focus:border-campfire-secondary"
/>
</div>
<div>
<label className="block text-campfire-gray mb-2">Категория</label>
<select
name="category"
value={formData.category}
onChange={handleChange}
required
className="w-full bg-campfire-charcoal border border-campfire-gray rounded px-3 py-2 text-campfire-light focus:outline-none focus:border-campfire-secondary"
>
<option value="movie">Фильм</option>
<option value="tv">Сериал</option>
<option value="anime">Аниме</option>
<option value="game">Игра</option>
</select>
</div>
<div>
<label className="block text-campfire-gray mb-2">Ссылка (опционально)</label>
<input
type="url"
name="link"
value={formData.link}
onChange={handleChange}
className="w-full bg-campfire-charcoal border border-campfire-gray rounded px-3 py-2 text-campfire-light focus:outline-none focus:border-campfire-secondary"
/>
</div>
<div className="flex justify-end space-x-3">
<button
type="submit"
className="px-4 py-2 bg-campfire-secondary text-campfire-dark rounded hover:bg-campfire-secondary/90"
>
Отправить
</button>
</div>
</form>
);
};
export default SuggestCardWidget;

View File

@ -0,0 +1,95 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { pb } from '../../services/pocketbaseService';
import { toast } from 'react-hot-toast';
import { FaSpinner } from 'react-icons/fa';
const SupportTicketForm = ({ onSuccess }) => {
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
subject: '',
message: ''
});
const handleSubmit = async (e) => {
e.preventDefault();
if (!user) {
toast.error('Необходимо войти в систему');
return;
}
try {
setLoading(true);
await pb.collection('support_tickets').create({
user_id: user.id,
subject: formData.subject,
message: formData.message,
status: 'open'
});
toast.success('Обращение успешно создано');
setFormData({ subject: '', message: '' });
if (onSuccess) {
onSuccess();
}
} catch (error) {
console.error('Error creating support ticket:', error);
toast.error('Не удалось создать обращение');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="subject" className="block text-sm font-medium text-campfire-light mb-1">
Тема обращения
</label>
<input
type="text"
id="subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
required
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-campfire-light mb-1">
Сообщение
</label>
<textarea
id="message"
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
rows="5"
required
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="btn-primary flex items-center space-x-2"
>
{loading ? (
<>
<FaSpinner className="animate-spin" />
<span>Отправка...</span>
</>
) : (
<span>Отправить</span>
)}
</button>
</div>
</form>
);
};
export default SupportTicketForm;

View File

@ -0,0 +1,13 @@
import React from 'react';
const AlphaBadge = () => {
return (
<div className="fixed top-2 right-2 z-[60]">
<div className="bg-campfire-charcoal/80 backdrop-blur-md px-3 py-1 rounded-full text-xs text-campfire-light border border-campfire-ash/20">
Alpha 6.2
</div>
</div>
);
};
export default AlphaBadge;

View File

@ -0,0 +1,11 @@
import React from 'react';
function AlphaBanner() {
return (
<div className="w-full bg-campfire-dark text-campfire-ash text-center text-sm py-1 font-medium border-b border-campfire-charcoal">
<span className="text-campfire-amber font-semibold mr-1">Alpha Version:</span> Возможны ошибки и изменения.
</div>
);
}
export default AlphaBanner;

View File

@ -0,0 +1,19 @@
import React from 'react';
import ShinyText from '../reactbits/TextAnimations/ShinyText/ShinyText';
const AlphaVersion = () => {
return (
<div className="fixed top-0 left-0 right-0 bg-campfire-charcoal/90 border-b border-campfire-ash/20 z-50">
<div className="container-custom py-0.5">
<ShinyText
text="ALPHA VERSION"
className="text-[8px] font-medium text-campfire-amber/90"
disabled={false}
speed={2}
/>
</div>
</div>
);
};
export default AlphaVersion;

View File

@ -0,0 +1,22 @@
import React from 'react';
function Logo({ size = "large" }) {
const sizeClasses = {
small: "w-6 h-6",
default: "w-8 h-8",
large: "w-16 h-16"
};
return (
<div className={`relative rounded-full flex items-center justify-center ${sizeClasses[size]}`}>
{/* Assuming logo.png is placed in the public folder */}
<img
src="/logo.png"
alt="CampFire мнеие"
className={`object-contain ${sizeClasses[size]}`} // Use object-contain to maintain aspect ratio
/>
</div>
);
}
export default Logo;

View File

@ -0,0 +1,103 @@
import React, { useState, useEffect, useRef } from 'react';
import { FiSearch, FiX } from 'react-icons/fi';
import { searchMedia } from '../../services/pocketbaseService';
import SearchResults from './SearchResults';
const SearchBar = ({ onClose }) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef(null);
// Фокус на инпут при монтировании
useEffect(() => {
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (query.length >= 2) {
setIsLoading(true);
searchMedia(query)
.then((data) => {
setResults(data);
setIsLoading(false);
})
.catch((error) => {
console.error('Search error:', error);
setIsLoading(false);
});
} else {
setResults([]);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [query]);
const handleInputChange = (e) => {
const value = e.target.value;
// Если это первый символ, делаем его заглавным
if (value.length === 1) {
setQuery(value.toUpperCase());
} else {
setQuery(value);
}
};
const handleResultClick = () => {
onClose(); // Закрываем поиск при клике на результат
};
return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-campfire-ash" />
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
placeholder="Че потерял?"
className="w-full pl-10 pr-4 py-2 bg-campfire-charcoal border border-campfire-ash/30 rounded-md text-campfire-light placeholder-campfire-ash focus:outline-none focus:ring-1 focus:ring-campfire-amber/100 focus:border-campfire-amber/50 transition-colors"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-campfire-ash hover:text-campfire-light"
>
<FiX />
</button>
)}
</div>
<button
onClick={onClose}
className="px-4 py-2 text-campfire-light hover:text-campfire-amber transition-colors"
>
Отмена
</button>
</div>
{/* Search Results */}
{(query.length >= 2 || results.length > 0) && (
<div className="fixed left-0 right-0 top-[72px] bg-campfire-charcoal/95 backdrop-blur-md border-b border-campfire-ash/30 shadow-lg">
<div className="container-custom mx-auto px-4 py-4">
{isLoading ? (
<div className="text-campfire-light text-center">Поиск...</div>
) : (
<SearchResults results={results} onResultClick={handleResultClick} />
)}
</div>
</div>
)}
</div>
);
};
export default SearchBar;

View File

@ -0,0 +1,54 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { getFileUrl } from '../../services/pocketbaseService';
const SearchResults = ({ results, onResultClick }) => {
if (!results || results.length === 0) {
return (
<div className="text-campfire-light text-center py-4">
Ничего не найдено
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{results.map((item) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="bg-campfire-charcoal/50 backdrop-blur-sm rounded-md overflow-hidden border border-campfire-ash/30"
>
<Link
to={`/media/${item.path}`}
className="block p-4 hover:bg-campfire-ash/20 transition-colors"
onClick={onResultClick}
>
<div className="flex items-start gap-4">
<img
src={item.poster ? getFileUrl(item, 'poster', { thumb: '100x150' }) : 'https://via.placeholder.com/100x150'}
alt={item.title}
className="w-24 h-36 object-cover rounded-md"
/>
<div className="flex-1">
<h3 className="text-campfire-light font-medium">{item.title}</h3>
<p className="text-campfire-ash text-sm mt-1">{item.type}</p>
{item.rating && (
<div className="mt-2 flex items-center gap-2">
<span className="text-campfire-amber">{item.rating}</span>
<span className="text-campfire-ash text-sm">/ 10</span>
</div>
)}
</div>
</div>
</Link>
</motion.div>
))}
</div>
);
};
export default SearchResults;

View File

@ -0,0 +1,13 @@
import React from 'react';
const TrueFocus = ({ children, className = '' }) => {
return (
<div className={`relative inline-block ${className}`}>
<div className="relative z-10">{children}</div>
<div className="absolute inset-0 bg-campfire-amber/20 blur-md animate-pulse rounded-md"></div>
<div className="absolute inset-0 bg-gradient-to-r from-campfire-amber/0 via-campfire-amber/30 to-campfire-amber/0 animate-shimmer rounded-md"></div>
</div>
);
};
export default TrueFocus;

View File

@ -0,0 +1,271 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useMemo,
} from "react";
import {
pb, // Import PocketBase instance for authStore listener
signUp as pbSignUp,
signIn as pbSignIn,
signOut as pbSignOut,
getUserProfile,
requestPasswordReset as pbRequestPasswordReset, // Import the new function
} from "../services/pocketbaseService"; // Use the new service file
const AuthContext = createContext();
export const useAuth = () => {
// CORRECT: useContext is called at the top level of the useAuth hook function
const context = useContext(AuthContext);
if (!context) {
// This error should ideally not happen if AuthProvider is used correctly
// and handles its own initialization state before rendering children.
console.error("useAuth must be used within an AuthProvider"); // Add error logging
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [userProfile, setUserProfile] = useState(null);
const [loading, setLoading] = useState(true); // Start in loading state
const [error, setError] = useState(null);
const [isInitialized, setIsInitialized] = useState(false); // Track initialization
console.log('AuthProvider: Component rendering. State:', { currentUser: !!currentUser, userProfile: !!userProfile, loading, error, isInitialized }); // Add logging
// Function to load user profile (kept for potential manual refresh)
const loadUserProfile = async (userId) => {
if (!userId) {
console.log('AuthProvider: loadUserProfile: Нет userId для загрузки профиля');
return null;
}
try {
console.log('AuthProvider: loadUserProfile: Загрузка профиля пользователя:', userId);
// In PocketBase, the auth model *is* the user record, so we don't need a separate profile fetch
// unless we need expanded relations not included in the auth model.
// For now, we assume the auth model is sufficient for basic profile info.
// If we needed expanded data, we'd use pb.collection('users').getOne(userId, { expand: '...' });
const profile = await pb.collection('users').getOne(userId); // Fetch the full user record
console.log('AuthProvider: loadUserProfile: Профиль загружен:', profile);
return profile;
} catch (error) {
console.error('AuthProvider: loadUserProfile: Ошибка загрузки профиля:', error); // Add error logging
// Don't set global error here, just log
return null;
}
};
useEffect(() => {
let mounted = true;
console.log('AuthProvider: useEffect: Инициализация PocketBase authStore listener...'); // Add logging
// Use PocketBase's authStore.onChange listener
// The 'true' argument runs the listener immediately on mount,
// which is perfect for initial auth state check.
const unsubscribe = pb.authStore.onChange(async (token, model) => {
if (!mounted) {
console.log('AuthProvider: authStore.onChange: Component unmounted, skipping state update.'); // Add logging
return;
}
console.log('AuthProvider: authStore.onChange: Изменение состояния авторизации:', { token: !!token, model: !!model }); // Add logging
// setLoading(true); // Avoid setting loading here to prevent flicker during state changes
setError(null); // Clear previous errors
try { // Wrap state update logic in try-catch
if (model) { // model is the authenticated user record
console.log('AuthProvider: authStore.onChange: Пользователь найден:', model.id); // Add logging
setCurrentUser(model); // Set the user model directly
// Fetch the full user record including any necessary expansions if the auth model is insufficient
// For now, let's assume the auth model has basic profile fields like username, avatar, etc.
// If you need expanded relations (like 'showcase'), you might need a separate fetch here
// or ensure they are included in the auth model via PocketBase settings if possible.
// For simplicity, let's just use the model as the profile for now.
setUserProfile(model); // In PocketBase, model is the user record
setError(null); // Clear error on successful user/profile load
} else {
console.log('AuthProvider: authStore.onChange: Пользователь не найден (вышел)'); // Add logging
setCurrentUser(null);
setUserProfile(null);
setError(null); // Clear error when user logs out
}
} catch (err) {
console.error('AuthProvider: authStore.onChange: Ошибка при обработке изменения состояния:', err); // Add error logging
setError(err.message || 'Ошибка при обработке состояния авторизации');
setCurrentUser(null);
setUserProfile(null);
} finally {
// Always set initialized and loading=false after the first check runs
// This ensures the provider is marked as ready after the initial auth state is determined.
if (!isInitialized) { // Only set initialized once
setIsInitialized(true);
console.log('AuthProvider: authStore.onChange: Initialized set to true.'); // Add logging
}
setLoading(false); // Always set loading to false after processing the change
console.log('AuthProvider: authStore.onChange: Обработка завершена. isInitialized:', isInitialized, 'loading:', loading); // Add logging
}
}, true); // The 'true' argument runs the listener immediately on mount
return () => {
console.log('AuthProvider: useEffect cleanup: Отписка от изменений состояния авторизации'); // Add logging
mounted = false;
unsubscribe(); // Unsubscribe from the listener
};
}, []); // Empty dependency array means this runs only once on mount
// Manual profile refresh function (useful after profile updates)
const refreshUserProfile = async () => {
if (currentUser) {
setLoading(true); // Indicate loading while refreshing profile
try {
console.log('AuthProvider: refreshUserProfile: Refreshing profile for user:', currentUser.id); // Add logging
// Fetch the latest user record
const profile = await pb.collection('users').getOne(currentUser.id);
setUserProfile(profile);
setError(null);
console.log('AuthProvider: refreshUserProfile: Profile refreshed successfully.'); // Add logging
} catch (err) {
console.error('AuthProvider: Ошибка при обновлении профиля:', err); // Add error logging
setError(err.message);
} finally {
setLoading(false);
console.log('AuthProvider: refreshUserProfile: Refresh complete.'); // Add logging
}
} else {
console.log('AuthProvider: refreshUserProfile: No current user to refresh profile.'); // Add logging
}
};
const signIn = async (email, password) => {
try {
setLoading(true); // Set loading at the start of the async operation
setError(null);
console.log('AuthProvider: signIn: Попытка входа для', email); // Add logging
const userRecord = await pbSignIn(email, password); // pbSignIn returns the record
console.log('AuthProvider: signIn: Вход успешен', userRecord); // Add logging
// authStore.onChange listener will handle setting currentUser and userProfile
return userRecord;
} catch (error) {
console.error('AuthProvider: signIn: Ошибка входа:', error); // Add error logging
setError(error.message);
throw error;
} finally {
// Loading is handled by authStore.onChange after state update
// However, if authStore.onChange doesn't fire (e.g., invalid credentials),
// we need to ensure loading is set to false here.
// Let's add a small delay or check authStore state to avoid race conditions
// with the authStore.onChange listener. A simple finally block might conflict.
// A better approach is to rely solely on authStore.onChange for loading state
// after the initial check, but ensure errors are caught and displayed.
// For now, let's ensure error is set and the UI reacts to it.
// setLoading(false); // Removed to rely on authStore.onChange
console.log('AuthProvider: signIn: Operation finished.'); // Add logging
}
};
const signUp = async (email, password, username) => {
try {
setLoading(true); // Set loading at the start of the async operation
setError(null);
console.log('AuthProvider: signUp: Попытка регистрации для', email); // Add logging
const { user, profile } = await pbSignUp(email, password, username); // pbSignUp returns user and profile
console.log('AuthProvider: signUp: Регистрация успешна', { user, profile }); // Add logging
// authStore.onChange listener will handle setting currentUser and userProfile
return { user, profile };
} catch (error) {
console.error('AuthProvider: signUp: Ошибка регистрации:', error); // Add error logging
setError(error.message);
throw error;
} finally {
// Loading is handled by authStore.onChange after state update
// setLoading(false); // Removed to rely on authStore.onChange
console.log('AuthProvider: signUp: Operation finished.'); // Add logging
}
};
const signOut = async () => {
try {
setLoading(true); // Set loading at the start of the async operation
setError(null);
console.log('AuthProvider: signOut: Попытка выхода'); // Add logging
await pbSignOut();
console.log('AuthProvider: signOut: Выход успешен'); // Add logging
// authStore.onChange listener will handle setting currentUser and userProfile
} catch (error) {
console.error('AuthProvider: signOut: Ошибка выхода:', error); // Add error logging
setError(error.message);
throw error;
} finally {
// Loading is handled by authStore.onChange after state update
// setLoading(false); // Removed to rely on authStore.onChange
console.log('AuthProvider: signOut: Operation finished.'); // Add logging
}
};
// New function for password reset request
const requestPasswordReset = async (email) => {
try {
setLoading(true);
setError(null);
console.log('AuthProvider: requestPasswordReset: Попытка сброса пароля для', email); // Add logging
await pbRequestPasswordReset(email);
console.log('AuthProvider: requestPasswordReset: Запрос на сброс пароля отправлен'); // Add logging
return true; // Indicate success
} catch (error) {
console.error('AuthProvider: requestPasswordReset: Ошибка при запросе сброса пароля:', error); // Add error logging
setError(error.message);
throw error;
} finally {
setLoading(false);
console.log('AuthProvider: requestPasswordReset: Operation finished.'); // Add logging
}
};
// Use useMemo for context value
const value = useMemo(() => ({
user: currentUser, // This is the PocketBase user record
userProfile, // This is the same as user in PocketBase context
loading,
error,
signIn, // Include signIn in the context value
signUp,
signOut,
isInitialized,
refreshUserProfile, // Add refresh function to context
requestPasswordReset, // Add password reset function
}), [currentUser, userProfile, loading, error, isInitialized, signIn, signUp, signOut, refreshUserProfile, requestPasswordReset]); // Add functions to dependency array
console.log('AuthProvider: Rendering with value:', { user: !!value.user, userProfile: !!value.userProfile, loading: value.loading, error: !!value.error, isInitialized: value.isInitialized }); // Add logging
// Render loading or error state until initialized
// Removed the error display block here to rely on pages displaying errors from context
// We still show a loading spinner if not initialized or loading
if (loading || !isInitialized) {
console.log('AuthProvider: Рендеринг состояния загрузки/инициализации...'); // Add logging
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
// Render children only when initialized and loading is false
console.log('AuthProvider: Инициализация завершена. Рендеринг детей.'); // Add logging
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@ -0,0 +1,40 @@
import React, { createContext, useContext, useCallback, useRef } from 'react';
import ClickSpark from '../components/reactbits/Animations/ClickSpark/ClickSpark';
const ClickSparkContext = createContext(null);
export const useClickSpark = () => {
const context = useContext(ClickSparkContext);
if (!context) {
throw new Error('useClickSpark must be used within a ClickSparkProvider');
}
return context;
};
export const ClickSparkProvider = ({ children }) => {
const clickSparkRef = useRef(null);
const addSpark = useCallback((x, y) => {
console.log('[ClickSpark] Добавление искры:', x, y);
if (clickSparkRef.current) {
clickSparkRef.current.addSpark(x, y);
}
}, []);
return (
<ClickSparkContext.Provider value={{ addSpark }}>
<ClickSpark
ref={clickSparkRef}
sparkColor="#FFA500"
sparkSize={10}
sparkRadius={15}
sparkCount={8}
duration={400}
easing="cubic-bezier(0.4, 0, 0.2, 1)"
extraScale={1.0}
>
{children}
</ClickSpark>
</ClickSparkContext.Provider>
);
};

View File

@ -0,0 +1,77 @@
import React, { createContext, useContext, useState } from 'react';
import { createMedia, validateMediaData, formatMediaData } from '../services/pocketbaseService'; // Import from pocketbaseService, including validation/formatting
const MediaContext = createContext();
export const useMedia = () => {
const context = useContext(MediaContext);
if (!context) {
throw new Error('useMedia must be used within a MediaProvider');
}
return context;
};
export const MediaProvider = ({ children }) => {
// Removed searchResults and selectedMedia states as search/details logic is likely handled elsewhere now
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Removed handleSearch and handleSelectMedia as they likely relied on the removed mediaService functionality
// If external search/details is needed, this logic will need to be reimplemented using a different service/API.
const handleCreateMedia = async (mediaData) => {
try {
setLoading(true);
setError(null);
console.log('MediaContext: Попытка создания медиа:', mediaData);
// Валидация данных (теперь из pocketbaseService)
const errors = validateMediaData(mediaData);
if (errors.length > 0) {
console.error('MediaContext: Ошибки валидации:', errors);
throw new Error('Ошибки валидации:\n' + errors.join('\n'));
}
console.log('MediaContext: Валидация данных успешна.');
// Форматирование данных (теперь из pocketbaseService)
const formattedData = formatMediaData(mediaData);
console.log('MediaContext: Данные отформатированы для PocketBase:', formattedData);
// Создание медиа через PocketBase
const newMedia = await createMedia(formattedData);
console.log('MediaContext: Медиа успешно создано:', newMedia);
return newMedia;
} catch (error) {
console.error('MediaContext: Ошибка при создании медиа:', error);
// PocketBase errors might have a response structure
if (error.response && error.response.data) {
console.error('MediaContext: PocketBase Response data:', error.response.data);
// Attempt to extract specific error messages from PocketBase response
const pbErrors = Object.values(error.response.data).map(err => err.message).join('\n');
setError('Ошибка при создании медиа:\n' + pbErrors || error.message || 'Неизвестная ошибка');
} else {
setError(error.message || 'Ошибка при создании медиа');
}
throw error; // Re-throw to allow calling component to handle
} finally {
setLoading(false);
}
};
const value = {
// Removed searchResults, selectedMedia
loading,
error,
// Removed handleSearch, handleSelectMedia
handleCreateMedia
};
return (
<MediaContext.Provider value={value}>
{children}
</MediaContext.Provider>
);
};

View File

@ -0,0 +1,36 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
const ProfileActionsContext = createContext(null);
export const ProfileActionsProvider = ({ children }) => {
const [shouldOpenEditModal, setShouldOpenEditModal] = useState(false);
const triggerEditModal = useCallback(() => {
setShouldOpenEditModal(true);
}, []);
const resetEditModalTrigger = useCallback(() => {
setShouldOpenEditModal(false);
}, []);
return (
<ProfileActionsContext.Provider value={{ shouldOpenEditModal, triggerEditModal, resetEditModalTrigger }}>
{children}
</ProfileActionsContext.Provider>
);
};
export const useProfileActions = () => {
const context = useContext(ProfileActionsContext);
if (!context) {
// This check is important. If useProfileActions is called outside the provider,
// it indicates a structural issue in the component tree.
// However, in this specific case, the Header is outside the ProfilePage route,
// so we need to handle the case where the context is null gracefully in the Header.
// We'll return null or undefined from the hook if context is not available.
// The components using the hook must check for null/undefined.
// console.warn('useProfileActions must be used within a ProfileActionsProvider');
return null; // Return null if context is not available
}
return context;
};

272
src/index.css Normal file
View File

@ -0,0 +1,272 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply antialiased;
}
body {
@apply bg-campfire-dark text-campfire-light;
user-select: none;
}
h1, h2, h3, h4, h5, h6 {
@apply font-bold;
}
}
/* Custom scrollbar styles */
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
width: 0;
}
.overflow-hidden {
overflow-x: hidden;
}
/* Hide scrollbar for IE, Edge and Firefox */
body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Keep custom thin scrollbar for specific elements if needed */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--campfire-amber) var(--campfire-charcoal);
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px; /* Added for horizontal scrollbar */
display: block; /* Override global hide for specific elements */
}
.scrollbar-thin::-webkit-webkit-scrollbar-track {
background: var(--campfire-charcoal);
border-radius: 10px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: var(--campfire-amber);
border-radius: 10px;
border: 2px solid var(--campfire-charcoal); /* Add border for thumb */
}
}
@layer components {
.container-custom {
@apply container mx-auto px-4 sm:px-6 lg:px-8;
}
.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200;
}
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-campfire-dark bg-campfire-secondary hover:bg-campfire-secondary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-campfire-secondary transition-colors duration-200;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-campfire-light bg-campfire-dark hover:bg-campfire-dark/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-campfire-dark transition-colors duration-200;
}
.btn-outline {
@apply inline-flex items-center justify-center px-6 py-3 border border-campfire-light text-base font-medium rounded-md shadow-sm text-campfire-light hover:bg-campfire-light/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-campfire-light transition-colors duration-200;
}
.input {
@apply block w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-dark rounded-md focus:outline-none focus:ring-2 focus:ring-campfire-secondary focus:border-campfire-secondary;
}
.label {
@apply block text-sm font-medium text-campfire-light/60 mb-1;
}
.card {
@apply bg-campfire-darker rounded-lg shadow-lg border border-campfire-dark/20 overflow-hidden;
}
.card-header {
@apply px-6 py-4 border-b border-campfire-dark/20;
}
.card-body {
@apply px-6 py-4;
}
.card-footer {
@apply px-6 py-4 border-t border-campfire-dark/20;
}
.input-field {
@apply block w-full px-4 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md shadow-sm placeholder-campfire-ash/60 focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber sm:text-sm;
}
.textarea-field {
@apply block w-full px-4 py-3 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md shadow-sm placeholder-campfire-ash/60 focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber sm:text-sm;
}
/* Styles for react-select */
.react-select__control {
@apply !bg-campfire-dark !border-campfire-ash/30 !rounded-md !shadow-sm !cursor-pointer;
}
.react-select__control--is-focused {
@apply !border-campfire-amber !ring-1 !ring-campfire-amber !shadow-none;
}
.react-select__value-container {
@apply !px-4 !py-1.5;
}
.react-select__single-value {
@apply !text-campfire-light;
}
.react-select__placeholder {
@apply !text-campfire-ash/60;
}
.react-select__indicator-separator {
@apply !bg-campfire-ash/30;
}
.react-select__dropdown-indicator {
@apply !text-campfire-ash/60 hover:!text-campfire-ash;
}
.react-select__menu {
@apply !bg-campfire-dark !rounded-md !shadow-lg !border !border-campfire-ash/30;
}
.react-select__option {
@apply !text-campfire-light !cursor-pointer;
}
.react-select__option--is-focused {
@apply !bg-campfire-charcoal;
}
.react-select__option--is-selected {
@apply !bg-campfire-amber !text-campfire-dark;
}
.react-select__multi-value {
@apply !bg-campfire-charcoal !rounded-md;
}
.react-select__multi-value-label {
@apply !text-campfire-light !px-2 !py-1;
}
.react-select__multi-value__remove {
@apply hover:!bg-red-500 hover:!text-white !rounded-r-md;
}
/* Styles for react-datepicker */
.react-datepicker__input-container input {
@apply input-field; /* Apply input-field styles */
}
.react-datepicker {
@apply !font-sans !bg-campfire-dark !border !border-campfire-ash/30 !rounded-md !shadow-lg;
}
.react-datepicker__header {
@apply !bg-campfire-charcoal !border-b !border-campfire-ash/30 !rounded-t-md;
}
.react-datepicker__current-month,
.react-datepicker__day-name,
.react-datepicker__navigation-icon::before {
@apply !text-campfire-light;
}
.react-datepicker__day {
@apply !text-campfire-light hover:!bg-campfire-charcoal !rounded-sm;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
@apply !bg-campfire-amber !text-campfire-dark;
}
.react-datepicker__day--outside-month {
@apply !text-campfire-ash/60;
}
.react-datepicker__day--disabled {
@apply !text-campfire-ash/30 !cursor-not-allowed;
}
.react-datepicker__navigation {
@apply !top-2;
}
.react-datepicker__navigation--previous {
@apply !left-2;
}
.react-datepicker__navigation--next {
@apply !right-2;
}
/* Modal Backdrop */
.modal-backdrop {
@apply fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50;
}
/* Modal Content */
.modal-content {
@apply bg-campfire-dark rounded-lg shadow-xl max-w-lg w-full p-6 border border-campfire-ash/30 max-h-[90vh] overflow-y-auto; /* Added max-h and overflow */
}
/* XP Progress Bar */
.xp-progress-bar-container {
@apply w-full bg-campfire-charcoal rounded-full h-2.5 relative overflow-hidden border border-campfire-ash/30; /* Added border */
}
.xp-progress-bar {
@apply bg-campfire-amber h-2.5 rounded-full transition-all duration-500 ease-in-out;
}
/* Footer text color fix */
footer p, footer a {
@apply text-campfire-ash; /* Ensure footer text is ash color */
}
/* Custom slider thumb style */
.slider-thumb-amber::-webkit-slider-thumb {
@apply appearance-none w-4 h-4 bg-campfire-amber rounded-full cursor-pointer shadow-md;
}
.slider-thumb-amber::-moz-range-thumb {
@apply appearance-none w-4 h-4 bg-campfire-amber rounded-full cursor-pointer shadow-md;
}
.slider-thumb-amber::-ms-thumb {
@apply appearance-none w-4 h-4 bg-campfire-amber rounded-full cursor-pointer shadow-md;
}
/* Custom slider track style */
.slider-thumb-amber::-webkit-slider-runnable-track {
@apply w-full h-2 bg-campfire-charcoal rounded-lg;
}
.slider-thumb-amber::-moz-range-track {
@apply w-full h-2 bg-campfire-charcoal rounded-lg;
}
.slider-thumb-amber::-ms-track {
@apply w-full h-2 bg-campfire-charcoal rounded-lg;
}
/* Remove default fill for IE/Edge */
.slider-thumb-amber::-ms-fill-lower {
background: transparent;
}
.slider-thumb-amber::-ms-fill-upper {
background: transparent;
}
/* Flame Rating Icons */
.flame-rating-icon {
@apply text-campfire-ash/50 cursor-pointer transition-colors duration-100;
}
.flame-rating-icon.filled {
@apply text-campfire-amber;
}
}

20
src/main.jsx Normal file
View File

@ -0,0 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx'
import './index.css'
import { AuthProvider } from './contexts/AuthContext'; // Import AuthProvider
console.log('main.jsx: Приложение инициализируется');
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<AuthProvider> {/* Wrap App with AuthProvider */}
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)
console.log('main.jsx: Приложение отрендерено');

View File

@ -0,0 +1,324 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
listMedia,
deleteMedia,
getFileUrl, // Import getFileUrl
mediaTypes // Import mediaTypes
} from '../services/pocketbaseService';
import { useAuth } from '../contexts/AuthContext';
import Modal from '../components/Modal';
import MediaForm from '../components/admin/MediaForm'; // Import MediaForm
const AdminMediaPage = () => {
const [media, setMedia] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentMedia, setCurrentMedia] = useState(null); // For editing
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filterType, setFilterType] = useState(''); // State for type filter
const [filterPublished, setFilterPublished] = useState(''); // State for published filter
const [sortField, setSortField] = useState('-created'); // State for sorting
const { userProfile } = useAuth();
const isAdminOrCritic = userProfile && (userProfile.role === 'admin' || userProfile.is_critic === true);
// Define sort options
const sortOptions = useMemo(() => [
{ value: '-created', label: 'Дата создания (новые)' },
{ value: 'created', label: 'Дата создания (старые)' },
{ value: '-title', label: 'Название (Я-А)' },
{ value: 'title', label: 'Название (А-Я)' },
{ value: '-average_rating', label: 'Рейтинг (убыв.)' },
{ value: 'average_rating', label: 'Рейтинг (возр.)' },
{ value: '-review_count', label: 'Рецензии (убыв.)' },
{ value: 'review_count', label: 'Рецензии (возр.)' },
], []);
const fetchMedia = async (currentPage, type, published, sort) => {
setLoading(true);
setError(null);
try {
// Pass filter and sort parameters to listMedia
const publishedFilter = published === '' ? null : published === 'true'; // Convert string to boolean or null
const { data, totalPages } = await listMedia(type || null, currentPage, 20, userProfile, false, publishedFilter, sort);
setMedia(data);
setTotalPages(totalPages);
} catch (err) {
console.error("Error fetching media:", err);
setError("Не удалось загрузить список контента.");
} finally {
setLoading(false);
}
};
useEffect(() => {
// Fetch media when page, filter, or sort changes
fetchMedia(page, filterType, filterPublished, sortField);
}, [page, filterType, filterPublished, sortField, userProfile]); // Add userProfile as dependency
const handleDelete = async (id) => {
if (window.confirm("Вы уверены, что хотите удалить этот контент?")) {
try {
await deleteMedia(id);
// Refresh the list after deletion
fetchMedia(page, filterType, filterPublished, sortField);
} catch (err) {
console.error("Error deleting media:", err);
setError("Не удалось удалить контент.");
}
}
};
const handleCreateClick = () => {
setCurrentMedia(null); // Clear currentMedia for creation
setIsModalOpen(true);
};
const handleEditClick = (mediaItem) => {
setCurrentMedia(mediaItem); // Set mediaItem for editing
setIsModalOpen(true);
};
const handleModalClose = () => {
setIsModalOpen(false);
setCurrentMedia(null); // Clear currentMedia
// Refresh the list after modal close (assuming create/edit happened)
fetchMedia(page, filterType, filterPublished, sortField);
};
if (!isAdminOrCritic) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center text-campfire-light">
<p>У вас нет прав для просмотра этой страницы.</p>
</div>
);
}
if (loading) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-campfire-light">
Управление <span className="font-semibold text-campfire-amber">контентом</span>
</h1>
<button onClick={handleCreateClick} className="btn-primary">
Добавить контент
</button>
</div>
{/* Filter and Sort Controls */}
<div className="mb-8 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="filterType" className="block text-sm font-medium text-campfire-light mb-1">
Фильтр по типу
</label>
<select
id="filterType"
value={filterType}
onChange={(e) => { setFilterType(e.target.value); setPage(1); }} // Reset page on filter change
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
>
<option value="">Все типы</option>
{Object.entries(mediaTypes).map(([key, value]) => (
<option key={key} value={key}>{value.label}</option>
))}
</select>
</div>
<div>
<label htmlFor="filterPublished" className="block text-sm font-medium text-campfire-light mb-1">
Фильтр по статусу публикации
</label>
<select
id="filterPublished"
value={filterPublished}
onChange={(e) => { setFilterPublished(e.target.value); setPage(1); }} // Reset page on filter change
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
>
<option value="">Все статусы</option>
<option value="true">Опубликовано</option>
<option value="false">Не опубликовано</option>
</select>
</div>
<div>
<label htmlFor="sortField" className="block text-sm font-medium text-campfire-light mb-1">
Сортировка
</label>
<select
id="sortField"
value={sortField}
onChange={(e) => { setSortField(e.target.value); setPage(1); }} // Reset page on sort change
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
{media.length > 0 ? (
<div className="overflow-x-auto bg-campfire-charcoal rounded-lg shadow-md border border-campfire-ash/20">
<table className="min-w-full divide-y divide-campfire-ash/20">
<thead className="bg-campfire-dark">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
>
Постер
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
>
Название
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
>
Тип
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
>
Опубликовано
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
>
Популярное
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
>
Рейтинг
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
>
Рецензии
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Действия</span>
</th>
</tr>
</thead>
<tbody className="bg-campfire-charcoal divide-y divide-campfire-ash/20 text-campfire-light">
{media.map((item) => (
<tr key={item.id}>
<td className="px-6 py-4 whitespace-nowrap">
<img
src={getFileUrl(item, 'poster', { thumb: '50x50' })} // Use getFileUrl
alt={item.title}
className="h-10 w-10 rounded object-cover"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-campfire-light">
<Link to={`/media/${item.path}`} className="hover:text-campfire-amber transition-colors">
{item.title}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{mediaTypes[item.type]?.label || item.type}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{item.is_published ? 'Да' : 'Нет'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{item.is_popular ? 'Да' : 'Нет'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{item.average_rating !== null && item.average_rating !== undefined ? item.average_rating.toFixed(1) : 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{item.review_count !== null && item.review_count !== undefined ? item.review_count : 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEditClick(item)}
className="text-campfire-amber hover:text-campfire-ember mr-4 transition-colors"
>
Редактировать
</button>
<button
onClick={() => handleDelete(item.id)}
className="text-status-error hover:text-red-700 transition-colors"
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-campfire-ash text-lg">
Нет доступного контента.
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center mt-8 space-x-4">
<button
onClick={() => setPage(prev => Math.max(prev - 1, 1))}
disabled={page === 1}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Предыдущая
</button>
<span className="text-campfire-light text-lg font-medium">
Страница {page} из {totalPages}
</span>
<button
onClick={() => setPage(prev => Math.min(prev + 1, totalPages))}
disabled={page === totalPages}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Следующая
</button>
</div>
)}
{/* Media Create/Edit Modal */}
<Modal isOpen={isModalOpen} onClose={handleModalClose}>
<MediaForm media={currentMedia} onSuccess={handleModalClose} />
</Modal>
</div>
</div>
);
};
export default AdminMediaPage;

View File

@ -0,0 +1,567 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import {
getMediaById, // To get the parent media title
getSeasonsByMediaId,
createSeason,
updateSeason,
deleteSeason,
getFileUrl, // To display season posters
uploadFile, // To upload season posters
deleteFile // To delete season posters
} from '../../services/pocketbaseService';
import Modal from '../../components/common/Modal';
import DatePicker from 'react-datepicker'; // Assuming you have react-datepicker installed
import "react-datepicker/dist/react-datepicker.css"; // Import styles
import { FaEdit, FaTrashAlt, FaPlus } from 'react-icons/fa'; // Import icons
// Season Form Component (can be reused for Add and Edit)
const SeasonForm = ({ season, mediaId, onSubmit, onCancel, isSubmitting, error }) => {
const { user } = useAuth(); // Get current user for created_by
const [formData, setFormData] = useState({
season_number: '',
title: '',
overview: '',
release_date: null,
// poster: null, // File object for new upload - Handled by separate state
is_published: false,
// media_id and created_by will be added in onSubmit handler
});
const [currentPosterUrl, setCurrentPosterUrl] = useState(null); // To display existing poster
const [posterFile, setPosterFile] = useState(null); // State for the selected file input
const [deleteExistingPoster, setDeleteExistingPoster] = useState(false); // State to track if existing poster should be deleted
useEffect(() => {
console.log('SeasonForm useEffect: season changed', season);
if (season) {
// Pre-fill form for editing
setFormData({
season_number: season.season_number || '',
title: season.title || '',
overview: season.overview || '',
release_date: season.release_date ? new Date(season.release_date) : null,
// poster: null, // Clear file input state - Handled by separate state
is_published: season.is_published ?? false,
});
// Set current poster URL if exists
setCurrentPosterUrl(season.poster ? getFileUrl(season, 'poster') : null);
setPosterFile(null); // Clear file input
setDeleteExistingPoster(false); // Reset delete flag
} else {
// Reset form for adding
setFormData({
season_number: '',
title: '',
overview: '',
release_date: null,
// poster: null,
is_published: false,
});
setCurrentPosterUrl(null);
setPosterFile(null);
setDeleteExistingPoster(false);
}
}, [season]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleDateChange = (date) => {
setFormData(prev => ({
...prev,
release_date: date
}));
};
const handleFileChange = (e) => {
const file = e.target.files?.[0] || null;
setPosterFile(file); // Store the file object
// If a new file is selected, don't delete the existing one
if (file) {
setDeleteExistingPoster(false);
}
};
const handleRemoveExistingPoster = () => {
setCurrentPosterUrl(null); // Hide the current poster preview
setDeleteExistingPoster(true); // Mark for deletion on submit
setPosterFile(null); // Ensure no new file is selected
};
const handleSubmit = async (e) => {
e.preventDefault();
// Basic validation
if (!formData.season_number || isNaN(parseInt(formData.season_number)) || parseInt(formData.season_number) <= 0) {
alert('Номер сезона обязателен и должен быть положительным числом.');
return;
}
// Create FormData for PocketBase
const dataToSend = new FormData();
dataToSend.append('media_id', mediaId); // Link to parent media
dataToSend.append('season_number', parseInt(formData.season_number)); // Ensure number type
dataToSend.append('title', formData.title || '');
dataToSend.append('overview', formData.overview || '');
if (formData.release_date) {
// PocketBase expects ISO string for datetime/date fields
dataToSend.append('release_date', formData.release_date.toISOString());
} else {
// Append empty string or null if date is optional and not set
// PocketBase handles empty string for optional date fields
dataToSend.append('release_date', '');
}
dataToSend.append('is_published', formData.is_published);
dataToSend.append('created_by', user.id); // Set creator to current user
// Handle poster file upload or deletion
if (posterFile) {
// Append the new file
dataToSend.append('poster', posterFile);
} else if (deleteExistingPoster) {
// If no new file and delete flag is true, signal deletion
// For FormData, set the file field to an empty string or empty FileList
// Empty string is simpler for single file fields
dataToSend.append('poster', ''); // Signal deletion
}
// If no new file and not deleting, do not append 'poster' field at all
// This prevents PocketBase from trying to update it if it's unchanged
onSubmit(dataToSend); // Pass FormData to parent handler
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
Ошибка: {error}
</div>
)}
{/* Season Number */}
<div>
<label htmlFor="season_number" className="block mb-2 text-campfire-light text-sm font-medium">
Номер сезона <span className="text-red-500">*</span>
</label>
<input
type="number"
id="season_number"
name="season_number"
value={formData.season_number}
onChange={handleChange}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
required
min="1"
/>
</div>
{/* Title */}
<div>
<label htmlFor="title" className="block mb-2 text-campfire-light text-sm font-medium">
Название сезона (опционально)
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
/>
</div>
{/* Overview */}
<div>
<label htmlFor="overview" className="block mb-2 text-campfire-light text-sm font-medium">
Описание сезона (опционально)
</label>
<textarea
id="overview"
name="overview"
rows="4"
value={formData.overview}
onChange={handleChange}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
/>
</div>
{/* Release Date */}
<div>
<label htmlFor="release_date" className="block mb-2 text-campfire-light text-sm font-medium">
Дата выхода (опционально)
</label>
<DatePicker
id="release_date"
selected={formData.release_date}
onChange={handleDateChange}
dateFormat="dd.MM.yyyy"
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
placeholderText="Выберите дату"
isClearable
/>
</div>
{/* Poster */}
<div>
<label htmlFor="poster" className="block mb-2 text-campfire-light text-sm font-medium">
Постер сезона (опционально)
</label>
{currentPosterUrl && !deleteExistingPoster && (
<div className="mb-4">
<p className="text-sm text-campfire-ash mb-2">Текущий постер:</p>
<img src={currentPosterUrl} alt="Current Poster" className="w-24 h-auto rounded-md mb-2" />
<button
type="button"
onClick={handleRemoveExistingPoster}
className="text-red-500 hover:underline text-sm"
>
Удалить текущий постер
</button>
</div>
)}
<input
type="file"
id="poster"
name="poster"
onChange={handleFileChange}
className="w-full text-campfire-light text-sm
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-campfire-amber file:text-campfire-dark
hover:file:bg-campfire-amber/80 cursor-pointer"
/>
{/* Hidden input is not needed with FormData append logic */}
{/* {deleteExistingPoster && <input type="hidden" name="poster" value="" />} */}
</div>
{/* Is Published */}
<div className="flex items-center">
<input
type="checkbox"
id="is_published"
name="is_published"
checked={formData.is_published}
onChange={handleChange}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2 cursor-pointer"
/>
<label htmlFor="is_published" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer">
Опубликовать сезон
</label>
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={onCancel}
className="btn-secondary"
disabled={isSubmitting}
>
Отмена
</button>
<button
type="submit"
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? (season ? 'Сохранение...' : 'Создание...') : (season ? 'Сохранить сезон' : 'Создать сезон')}
</button>
</div>
</form>
);
};
const AdminSeasonsPage = () => {
const { mediaId } = useParams(); // Get mediaId from URL
const navigate = useNavigate();
const { user, userProfile, loading: authLoading } = useAuth();
const [media, setMedia] = useState(null); // To store parent media details
const [seasons, setSeasons] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [seasonToEdit, setSeasonToEdit] = useState(null);
const [formError, setFormError] = useState(null); // Error for the form modal
const [isSubmittingForm, setIsSubmittingForm] = useState(false); // Submitting state for form
// Check if the current user is admin
const isAdmin = userProfile?.role === 'admin';
// Use useCallback to memoize loadData
const loadData = useCallback(async () => {
if (!mediaId) {
setError('ID медиа не указан.');
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// Fetch parent media details
const mediaData = await getMediaById(mediaId);
if (!mediaData) {
setError('Родительское медиа не найдено.');
setLoading(false);
return;
}
setMedia(mediaData);
// Fetch seasons for this media
const seasonsData = await getSeasonsByMediaId(mediaId);
// Sort seasons by season_number
seasonsData.sort((a, b) => a.season_number - b.season_number);
setSeasons(seasonsData || []);
} catch (err) {
console.error('Error loading seasons data:', err);
setError('Не удалось загрузить данные сезонов.');
} finally {
setLoading(false);
}
}, [mediaId]); // Depend on mediaId
useEffect(() => {
console.log('AdminSeasonsPage mounted, mediaId:', mediaId, 'user:', user);
// Wait for auth to finish loading before checking user/profile
if (authLoading) {
console.log('AdminSeasonsPage: Auth loading...');
return;
}
// Redirect if not admin (AdminRoute should handle this, but double-check)
if (!user || !isAdmin) {
console.warn('AdminSeasonsPage: User is not admin, redirecting.');
navigate('/admin'); // Redirect to admin dashboard or login
return;
}
// Load data if user is admin and mediaId is available
if (user && isAdmin && mediaId) {
loadData();
}
}, [mediaId, user, userProfile, authLoading, navigate, isAdmin, loadData]); // Depend on auth states, navigate, loadData
const handleAddSeason = () => {
setSeasonToEdit(null); // Ensure we are adding, not editing
setFormError(null); // Clear previous form errors
setIsAddModalOpen(true);
};
const handleEditSeason = (season) => {
setSeasonToEdit(season); // Set season to edit
setFormError(null); // Clear previous form errors
setIsEditModalOpen(true);
};
const handleDeleteSeason = async (seasonId) => {
if (!window.confirm('Вы уверены, что хотите удалить этот сезон? Это также удалит все связанные с ним рецензии!')) {
return;
}
try {
setLoading(true); // Show main loading indicator
setError(null); // Clear main error
await deleteSeason(seasonId);
loadData(); // Reload the list after deletion
} catch (err) {
console.error('Error deleting season:', err);
setError('Не удалось удалить сезон.');
setLoading(false); // Hide loading on error
}
};
const handleFormSubmit = async (formData) => {
setIsSubmittingForm(true);
setFormError(null); // Clear previous form errors
try {
if (seasonToEdit) {
// Update existing season
await updateSeason(seasonToEdit.id, formData);
console.log('Season updated successfully.');
} else {
// Create new season
await createSeason(formData);
console.log('Season created successfully.');
}
// Close modal and reload data
setIsAddModalOpen(false);
setIsEditModalOpen(false);
setSeasonToEdit(null);
loadData(); // Reload the list after add/edit
} catch (err) {
console.error('Error submitting season form:', err);
// Set form-specific error
setFormError(err.message || 'Произошла ошибка при сохранении сезона.');
} finally {
setIsSubmittingForm(false);
}
};
const handleFormCancel = () => {
setIsAddModalOpen(false);
setIsEditModalOpen(false);
setSeasonToEdit(null);
setFormError(null); // Clear form error on cancel
};
if (authLoading || loading) {
return <div className="flex justify-center items-center h-screen text-campfire-light">Загрузка...</div>;
}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
if (!media) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="text-campfire-ash text-lg">Родительское медиа не найдено.</div>
</div>
);
}
return (
<div> {/* Removed container-custom and pt-20 */}
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-bold text-campfire-light">
Сезоны для "{media.title}"
</h2>
<button
onClick={handleAddSeason}
className="btn-primary flex items-center space-x-2"
>
<FaPlus size={18} />
<span>Добавить сезон</span>
</button>
</div>
{seasons.length === 0 ? (
<div className="text-center py-8 text-campfire-light">
Сезоны не найдены. Добавьте первый сезон!
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{seasons.map((season) => (
<div
key={season.id}
className="bg-campfire-charcoal rounded-lg overflow-hidden border border-campfire-ash/20 flex flex-col" // Use flex-col
>
{season.poster && (
<img
src={getFileUrl(season, 'poster')}
alt={`Постер сезона ${season.season_number}`}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4 flex-grow flex flex-col"> {/* Use flex-grow and flex-col */}
<h3 className="text-xl font-semibold text-campfire-amber mb-2">
Сезон {season.season_number} {season.title ? `- ${season.title}` : ''}
</h3>
{season.overview && (
<p className="text-campfire-light text-sm mb-2 line-clamp-3 flex-grow"> {/* Use flex-grow */}
{season.overview}
</p>
)}
{season.release_date && (
<p className="text-campfire-ash text-sm mb-4">
Дата выхода: {new Date(season.release_date).toLocaleDateString()}
</p>
)}
{/* Display season rating and review count */}
<div className="flex items-center text-campfire-ash text-sm mt-2">
<span className="text-campfire-amber mr-2 font-bold">
{season.average_rating !== null && season.average_rating !== undefined && !isNaN(parseFloat(season.average_rating)) ? parseFloat(season.average_rating).toFixed(1) : 'N/A'} / 10
</span>
<FaFire className="mr-2 text-base" />
<span>
{season.review_count !== null && season.review_count !== undefined && !isNaN(parseInt(season.review_count)) ? parseInt(season.review_count) : 0} рецензий
</span>
</div>
<div className="flex justify-end space-x-4 mt-auto"> {/* Use mt-auto to push to bottom */}
<button
onClick={() => handleEditSeason(season)}
className="text-campfire-amber hover:text-campfire-light flex items-center space-x-1"
title="Редактировать сезон"
>
<FaEdit size={18} />
</button>
<button
onClick={() => handleDeleteSeason(season.id)}
className="text-red-500 hover:text-red-400 flex items-center space-x-1"
title="Удалить сезон"
>
<FaTrashAlt size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Add Season Modal */}
<Modal
isOpen={isAddModalOpen}
onClose={handleFormCancel}
title={`Добавить сезон для "${media?.title || ''}"`}
size="lg"
>
<SeasonForm
mediaId={mediaId}
onSubmit={handleFormSubmit}
onCancel={handleFormCancel}
isSubmitting={isSubmittingForm}
error={formError}
/>
</Modal>
{/* Edit Season Modal */}
<Modal
isOpen={isEditModalOpen}
onClose={handleFormCancel}
title={`Редактировать сезон ${seasonToEdit?.season_number || ''} для "${media?.title || ''}"`}
size="lg"
>
<SeasonForm
season={seasonToEdit} // Pass the season data for editing
mediaId={mediaId}
onSubmit={handleFormSubmit}
onCancel={handleFormCancel}
isSubmitting={isSubmittingForm}
error={formError}
/>
</Modal>
</div>
);
};
export default AdminSeasonsPage;

331
src/pages/CatalogPage.jsx Normal file
View File

@ -0,0 +1,331 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import { listMedia, mediaTypes, getFileUrl } from '../services/pocketbaseService';
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
import { useAuth } from '../contexts/AuthContext';
import Select from 'react-select';
const typeOptions = [
{ value: '', label: 'Все типы' },
...Object.entries(mediaTypes).map(([key, value]) => ({ value: key, label: value.label }))
];
const sortOptions = [
{ value: '-created', label: 'Новые сначала' },
{ value: 'created', label: 'Старые сначала' },
{ value: 'title', label: 'По названию (А-Я)' },
{ value: '-title', label: 'По названию (Я-А)' },
{ value: '-average_rating', label: 'По рейтингу (сначала высокие)' },
{ value: 'average_rating', label: 'По рейтингу (сначала низкие)' },
{ value: '-review_count', label: 'По количеству рецензий (сначала больше)' },
{ value: 'review_count', label: 'По количеству рецензий (сначала меньше)' },
{ value: '-release_date', label: 'По дате выхода (сначала новые)' },
{ value: 'release_date', label: 'По дате выхода (сначала старые)' }
];
const yearFilterOptions = [
{ value: '', label: 'Любой год' },
{ value: '2025', label: '2025' },
{ value: '2024', label: '2024' },
{ value: '2023', label: '2023' },
{ value: '2022', label: '2022' },
{ value: '2021', label: '2021' },
{ value: '2020', label: '2020' },
{ value: 'older', label: 'Раньше 2020' }
];
const CatalogPage = () => {
const location = useLocation();
const navigate = useNavigate();
const { userProfile } = useAuth();
const [mediaList, setMediaList] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
// State for filters and sorting, initialized from URL params
const [selectedType, setSelectedType] = useState(typeOptions[0]);
const [selectedSort, setSelectedSort] = useState(sortOptions[0]);
const [selectedYear, setSelectedYear] = useState(yearFilterOptions[0]);
// Custom styles for react-select
const selectStyles = {
control: (provided, state) => ({
...provided,
backgroundColor: '#2a2a2a', // campfire-dark
borderColor: state.isFocused ? '#f59e0b' : '#4a4a4a', // campfire-amber / campfire-ash/30
color: '#e0e0e0', // campfire-light
boxShadow: state.isFocused ? '0 0 0 1px #f59e0b' : 'none',
'&:hover': {
borderColor: state.isFocused ? '#f59e0b' : '#6a6a6a', // campfire-amber / campfire-ash/60
},
cursor: 'pointer',
}),
singleValue: (provided) => ({
...provided,
color: '#e0e0e0', // campfire-light
}),
input: (provided) => ({
...provided,
color: '#e0e0e0', // campfire-light
}),
placeholder: (provided) => ({
...provided,
color: '#a0a0a0', // campfire-ash
}),
menu: (provided) => ({
...provided,
backgroundColor: '#2a2a2a', // campfire-dark
zIndex: 9999, // Ensure dropdown is above other content
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isFocused ? '#3a3a3a' : '#2a2a2a', // Darker hover / campfire-dark
color: state.isSelected ? '#f59e0b' : '#e0e0e0', // campfire-amber / campfire-light
cursor: 'pointer',
}),
};
// Effect to read URL params on mount and location change
useEffect(() => {
const params = new URLSearchParams(location.search);
const typeParam = params.get('type') || '';
const sortParam = params.get('sort') || '-created';
const pageParam = parseInt(params.get('page') || '1', 10);
const yearParam = params.get('year') || '';
const initialType = typeOptions.find(opt => opt.value === typeParam) || typeOptions[0];
const initialSort = sortOptions.find(opt => opt.value === sortParam) || sortOptions[0];
const initialYear = yearFilterOptions.find(opt => opt.value === yearParam) || yearFilterOptions[0];
setSelectedType(initialType);
setSelectedSort(initialSort);
setSelectedYear(initialYear);
setPage(pageParam);
// Initial data fetch based on URL params
fetchMediaData(initialType.value, initialSort.value, pageParam);
}, [location.search]);
// Function to update URL search params
const updateUrlParams = useCallback((type, sort, page, year) => {
const params = new URLSearchParams();
if (type) params.set('type', type);
if (sort) params.set('sort', sort);
if (page > 1) params.set('page', page);
if (year) params.set('year', year);
navigate({ search: params.toString() }, { replace: true });
}, [navigate]);
// Function to fetch media data
const fetchMediaData = useCallback(async (type, sort, pageNum) => {
try {
setLoading(true);
setError(null);
// Создаем дополнительные фильтры
const additionalFilters = [];
// Фильтр по году
if (selectedYear.value) {
if (selectedYear.value === 'older') {
additionalFilters.push(`release_date < "2020-01-01 00:00:00"`);
} else {
const year = parseInt(selectedYear.value);
const startDate = `${year}-01-01 00:00:00`;
const endDate = `${year + 1}-01-01 00:00:00`;
additionalFilters.push(`release_date >= "${startDate}" && release_date < "${endDate}"`);
}
}
// Объединяем все фильтры
const filterString = additionalFilters.length > 0 ? additionalFilters.join(' && ') : '';
console.log('Fetching with filters:', {
type,
sort,
pageNum,
filterString,
selectedYear: selectedYear.value
});
const { data, count, totalPages: fetchedTotalPages } = await listMedia(
type || null,
pageNum,
20,
userProfile,
false,
true,
sort,
filterString
);
setMediaList(data);
setTotalItems(count);
setTotalPages(fetchedTotalPages);
} catch (err) {
console.error('Error fetching catalog media:', err);
setError('Не удалось загрузить список медиа.');
} finally {
setLoading(false);
}
}, [userProfile, selectedYear]);
// Effect to refetch data when filters/sort/page change
useEffect(() => {
fetchMediaData(selectedType.value, selectedSort.value, page);
}, [selectedType.value, selectedSort.value, page, fetchMediaData]);
const handleTypeChange = useCallback((selectedOption) => {
setSelectedType(selectedOption);
setPage(1);
fetchMediaData(selectedOption.value, selectedSort.value, 1);
}, [selectedSort.value, fetchMediaData]);
const handleSortChange = useCallback((selectedOption) => {
setSelectedSort(selectedOption);
setPage(1);
fetchMediaData(selectedType.value, selectedOption.value, 1);
}, [selectedType.value, fetchMediaData]);
const handleYearChange = useCallback((selectedOption) => {
setSelectedYear(selectedOption);
setPage(1);
fetchMediaData(selectedType.value, selectedSort.value, 1);
}, [selectedType.value, selectedSort.value, fetchMediaData]);
const handlePageChange = useCallback((newPage) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
fetchMediaData(selectedType.value, selectedSort.value, newPage);
}
}, [selectedType.value, selectedSort.value, totalPages, fetchMediaData]);
if (loading) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-campfire-dark pt-20 container-custom py-8">
<h1 className="text-3xl font-bold mb-6 text-campfire-light">Каталог медиа</h1>
{/* Filter and Sort Controls */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<div>
<label htmlFor="type-select" className="block mb-2 text-campfire-light text-sm font-medium">
Тип медиа:
</label>
<Select
id="type-select"
options={typeOptions}
value={selectedType}
onChange={handleTypeChange}
styles={selectStyles}
isSearchable={false}
classNamePrefix="react-select"
/>
</div>
<div>
<label htmlFor="year-select" className="block mb-2 text-campfire-light text-sm font-medium">
Год выхода:
</label>
<Select
id="year-select"
options={yearFilterOptions}
value={selectedYear}
onChange={handleYearChange}
styles={selectStyles}
isSearchable={false}
classNamePrefix="react-select"
/>
</div>
<div>
<label htmlFor="sort-select" className="block mb-2 text-campfire-light text-sm font-medium">
Сортировка:
</label>
<Select
id="sort-select"
options={sortOptions}
value={selectedSort}
onChange={handleSortChange}
styles={selectStyles}
isSearchable={false}
classNamePrefix="react-select"
/>
</div>
</div>
{mediaList.length > 0 ? (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-8 md:gap-15">
{mediaList.map((mediaItem) => (
<Link
key={mediaItem.id}
to={`/media/${mediaItem.path}`}
className="block group"
>
<TiltedCard
imageSrc={mediaItem.poster ? getFileUrl(mediaItem, 'poster', { thumb: '1000x1000' }) : 'https://via.placeholder.com/300x450'}
captionText={mediaItem.title}
rating={mediaItem.average_rating}
releaseDate={mediaItem.release_date}
containerHeight="360px"
containerWidth="100%"
imageHeight="360px"
imageWidth="240px"
/>
</Link>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center space-x-4 mt-8 text-campfire-light">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Предыдущая
</button>
<span>
Страница {page} из {totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Следующая
</button>
</div>
)}
</>
) : (
<p className="text-campfire-ash text-center py-8">
По вашему запросу ничего не найдено.
</p>
)}
</div>
);
};
export default CatalogPage;

110
src/pages/DiscoverPage.jsx Normal file
View File

@ -0,0 +1,110 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { listMedia } from '../services/pocketbaseService'; // Import listMedia
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard'; // Import TiltedCard
import { useAuth } from '../contexts/AuthContext'; // Import useAuth
const DiscoverPage = () => {
const { type } = useParams();
const [pageTitle, setPageTitle] = useState('');
const [mediaList, setMediaList] = useState([]); // State for media list
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { userProfile } = useAuth(); // Get userProfile from auth context
useEffect(() => {
// Set page title based on the 'type' parameter
let title = '';
switch (type) {
case 'movie': // Changed from 'movies' to 'movie' to match Supabase type
title = 'Фильмы';
break;
case 'tv':
title = 'Сериалы';
break;
case 'game': // Changed from 'games' to 'game' to match Supabase type
title = 'Игры';
break;
case 'anime': // Added case for Anime
title = 'Аниме';
break;
default:
title = 'Неизвестный тип';
break;
}
setPageTitle(title);
// Fetch media based on type
const fetchMedia = async () => {
try {
setLoading(true);
setError(null);
console.log(`Attempting to fetch media for type: ${type}`); // Added logging
// Use listMedia to fetch data filtered by type
// listMedia now returns data with average_rating and review_count
// Pass the current user to listMedia for permission checks if needed
// Pass the type filter
// Default sort for discover pages could be by creation date or title
const { data } = await listMedia(type, 1, 20, userProfile, null, true, '-created'); // Pass type filter, userProfile, only published, default sort
console.log('Successfully fetched media:', data); // Added logging
// The data from listMedia is already formatted with average_rating and review_count
setMediaList(data);
} catch (err) {
console.error('Error fetching media list:', err);
// Check if the error is a ClientResponseError and provide specific message if possible
if (err.response && err.response.message) {
setError(`Ошибка загрузки: ${err.response.message}`);
} else {
setError('Не удалось загрузить список медиа');
}
} finally {
setLoading(false);
}
};
if (type) {
fetchMedia();
} else {
// Handle case where type is not provided (e.g., /discover)
setLoading(false);
setMediaList([]);
}
}, [type, userProfile]); // Re-run effect when type or userProfile changes
if (loading) {
return <div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center"><div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div></div>;
}
if (error) {
return <div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center"><div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">{error}</div></div>;
}
return (
// Added min-h-screen and bg-campfire-dark to ensure dark background
<div className="min-h-screen bg-campfire-dark pt-20 container-custom py-8"> {/* Added bg-campfire-dark */}
<h1 className="text-3xl font-bold mb-6 text-campfire-light">Раздел: {pageTitle}</h1>
{mediaList.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-8 md:gap-20">
{mediaList.map((mediaItem) => (
<TiltedCard
key={mediaItem.id}
imageSrc={mediaItem.poster_path}
captionText={mediaItem.title}
rating={mediaItem.rating}
releaseDate={mediaItem.release_date}
/>
))}
</div>
) : (
<p className="text-campfire-ash">
Пока нет медиа в этом разделе.
</p>
)}
</div>
);
};
export default DiscoverPage;

View File

@ -0,0 +1,39 @@
import React from 'react';
const ForgotPasswordPage = () => {
return (
<div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark text-campfire-light">
<div className="container-custom max-w-md py-12">
<div className="bg-campfire-charcoal rounded-lg shadow-lg p-8 border border-campfire-ash/20 text-center">
<h1 className="text-2xl font-bold text-campfire-light mb-4">
Сброс пароля
</h1>
<p className="text-campfire-ash mb-6">
Функция сброса пароля требует настройки отправки электронной почты в PocketBase.
Пожалуйста, настройте SMTP в админ-панели PocketBase.
</p>
<form className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-campfire-light mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="your@email.com"
required
/>
</div>
<button type="submit" className="btn-primary w-full">
Отправить ссылку для сброса
</button>
</form>
</div>
</div>
</div>
);
};
export default ForgotPasswordPage;

467
src/pages/HomePage.jsx Normal file
View File

@ -0,0 +1,467 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { getLatestReviews, listMedia, listUsersRankedByReviews, getFileUrl, getFeaturedMedia } from '../services/pocketbaseService';
import { useAuth } from '../contexts/AuthContext';
import LatestReviewsMarquee from '../components/reviews/LatestReviewsMarquee';
import { FaFire, FaUsers, FaCrown, FaMedal } from 'react-icons/fa';
import StatsSection from '../components/home/StatsSection';
import GridMotion from '../components/reactbits/Backgrounds/GridMotion/GridMotion.jsx';
import { useProfileActions } from '../contexts/ProfileActionsContext';
import { getMediaCount, getReviewsCount } from '../services/pocketbaseService';
import { FaBook, FaTrophy, FaUser, FaCog } from 'react-icons/fa';
import { useClickSpark } from '../contexts/ClickSparkContext';
import HeroSection from '../components/home/HeroSection';
import GridSection from '../components/home/GridSection';
import FeaturedMedia from '../components/home/FeaturedMedia';
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
import { motion, AnimatePresence } from 'framer-motion';
const HomePage = () => {
const { userProfile } = useAuth();
const { profile } = useProfileActions();
const { addSpark } = useClickSpark();
const [popularMedia, setPopularMedia] = useState([]);
const [latestAddedMedia, setLatestAddedMedia] = useState([]);
const [latestReviews, setLatestReviews] = useState([]);
const [topUsers, setTopUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [stats, setStats] = useState({ mediaCount: 0, reviewsCount: 0 });
const [posters, setPosters] = useState([]);
const [featuredMedia, setFeaturedMedia] = useState([]);
const [isDataReady, setIsDataReady] = useState(false);
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.5
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: "easeOut"
}
}
};
const loadingVariants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: {
duration: 0.5
}
},
exit: {
opacity: 0,
transition: {
duration: 0.5
}
}
};
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [
popularMediaData,
latestAddedMediaData,
latestReviewsData,
topUsersData,
mediaCount,
reviewsCount
] = await Promise.all([
listMedia(null, 1, 50, userProfile, true, true, '-review_count'),
listMedia(null, 1, 30, userProfile, false, true, '-created'),
getLatestReviews(10),
listUsersRankedByReviews(3),
getMediaCount(),
getReviewsCount()
]);
console.log('[HomePage] Получены медиа:', {
total: popularMediaData.count,
items: popularMediaData.data.map(item => ({
id: item.id,
title: item.title,
poster: item.poster
}))
});
setPopularMedia(popularMediaData.data || []);
setLatestAddedMedia(latestAddedMediaData.data || []);
setLatestReviews(latestReviewsData || []);
setTopUsers(topUsersData || []);
setStats({
mediaCount: parseInt(mediaCount) || 0,
reviewsCount: parseInt(reviewsCount) || 0
});
const posterUrls = latestAddedMediaData.data
.filter(item => {
console.log('[HomePage] Проверка постера для:', item.title, 'Постер:', item.poster);
return item.poster;
})
.slice(0, 30)
.map(item => {
const url = getFileUrl(item, 'poster');
console.log('[HomePage] URL постера для:', item.title, 'URL:', url);
return url;
});
console.log('[HomePage] Итоговый список постеров:', posterUrls);
setPosters(posterUrls);
await new Promise(resolve => setTimeout(resolve, 1000));
setIsDataReady(true);
setLoading(false);
} catch (err) {
console.error('[HomePage] Ошибка загрузки данных:', err);
setError('Не удалось загрузить данные главной страницы.');
setLoading(false);
}
}, [userProfile]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const handleClick = (e) => {
console.log('[ClickSpark] Добавление искры:', e.clientX, e.clientY);
addSpark(e.clientX, e.clientY);
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [addSpark]);
useEffect(() => {
const fetchFeaturedMedia = async () => {
try {
const media = await getFeaturedMedia();
setFeaturedMedia(media);
} catch (error) {
console.error('Error fetching featured media:', error);
}
};
fetchFeaturedMedia();
}, []);
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
// Helper function to get pedestal styles based on rank
const getPedestalStyles = (rank) => {
switch (rank) {
case 1:
return {
container: 'bg-gradient-to-br from-yellow-400 to-yellow-600 text-campfire-dark border-yellow-700',
avatarBorder: 'border-yellow-700',
iconColor: 'text-yellow-800',
crown: <FaCrown className="text-yellow-800 text-3xl absolute -top-4 right-1/2 translate-x-1/2" />
};
case 2:
return {
container: 'bg-gradient-to-br from-gray-400 to-gray-600 text-campfire-dark border-gray-700',
avatarBorder: 'border-gray-700',
iconColor: 'text-gray-800',
medal: <FaMedal className="text-gray-800 text-2xl absolute -top-3 right-1/2 translate-x-1/2" />
};
case 3:
return {
container: 'bg-gradient-to-br from-amber-700 to-amber-900 text-campfire-dark border-amber-950',
avatarBorder: 'border-amber-950',
iconColor: 'text-amber-950',
medal: <FaMedal className="text-amber-950 text-2xl absolute -top-3 right-1/2 translate-x-1/2" />
};
default:
return {
container: 'bg-campfire-charcoal text-campfire-light border-campfire-ash/20',
avatarBorder: 'border-campfire-dark',
iconColor: 'text-campfire-amber'
};
}
};
const renderTopReviewers = () => {
return (
<div className="space-y-4">
{topUsers.map((user) => (
<div key={user.id} className="flex items-center space-x-4 bg-campfire-dark/50 p-4 rounded-lg">
<Link to={`/profile/${user.username}`} className="flex-shrink-0">
<img
src={getFileUrl(user, 'profile_picture', { thumb: '100x100' }) || '/default-avatar.png'}
alt={user.username}
className="w-12 h-12 rounded-full object-cover"
/>
</Link>
<div className="flex-grow">
<Link to={`/profile/${user.username}`} className="text-campfire-light hover:text-campfire-gold">
<h3 className="text-lg font-semibold">{user.username}</h3>
</Link>
<p className="text-campfire-ash text-sm">Рецензий: {user.review_count}</p>
</div>
</div>
))}
</div>
);
};
console.log(posters)
return (
<AnimatePresence mode="wait">
{loading || !isDataReady ? (
<motion.div
key="loading"
className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center"
variants={loadingVariants}
initial="initial"
animate="animate"
exit="exit"
>
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-campfire-amber"></div>
<motion.p
className="text-campfire-light text-lg"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
Загрузка...
</motion.p>
</div>
</motion.div>
) : (
<motion.div
key="content"
className="min-h-screen bg-campfire-dark"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<div className="">
{/* Grid Section with Stats Overlay */}
<motion.div variants={itemVariants}>
<GridSection posters={posters} stats={stats} />
</motion.div>
{/* Marquee for Latest Reviews */}
<motion.div variants={itemVariants}>
<LatestReviewsMarquee reviews={latestReviews} />
</motion.div>
{/* Content Grid */}
<motion.div
className="py-8 grid grid-cols-1 gap-8 mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8"
variants={containerVariants}
>
<div className="space-y-12">
{/* Popular Section */}
<motion.div variants={itemVariants}>
<h2 className="text-2xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-5">Популярное</h2>
{popularMedia.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-8 md:gap-20">
{popularMedia.map((mediaItem) => (
<motion.div
key={mediaItem.id}
variants={itemVariants}
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
>
<Link
to={`/media/${mediaItem.path}`}
className="block group"
>
<TiltedCard
imageSrc={mediaItem.poster ? getFileUrl(mediaItem, 'poster', { thumb: '300x450' }) : 'https://via.placeholder.com/300x450'}
altText={mediaItem.title}
captionText={mediaItem.title}
containerHeight="360px"
containerWidth="100%"
imageHeight="360px"
imageWidth="240px"
scaleOnHover={1.05}
rotateAmplitude={10}
showMobileWarning={false}
showTooltip={false}
displayOverlayContent={false}
rating={mediaItem.average_rating}
releaseDate={mediaItem.release_date}
/>
</Link>
</motion.div>
))}
</div>
) : (
<p className="text-campfire-ash text-center py-8">Нет данных о популярном медиа.</p>
)}
</motion.div>
{/* Top Users Section */}
<motion.div variants={itemVariants}>
<h2 className="text-2xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4">Топ рецензенты</h2>
{topUsers.length > 0 ? (
<div className="flex flex-col items-center space-y-6">
{/* Pedestal Layout */}
<div className="flex justify-center items-end w-full gap-4">
{/* Second Place */}
{topUsers[1] && (
<motion.div
className="flex flex-col items-center w-1/3"
variants={itemVariants}
>
<div className="relative">
<div className="w-24 h-24 rounded-full overflow-hidden border-2 border-campfire-ash/30 mb-2">
<img
src={topUsers[1].profile_picture ? getFileUrl(topUsers[1], 'profile_picture') : 'https://via.placeholder.com/150'}
alt={topUsers[1].username}
className="w-full h-full object-cover"
/>
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-campfire-ash/30 rounded-full flex items-center justify-center">
<span className="text-campfire-light font-bold">2</span>
</div>
</div>
<Link to={`/profile/${topUsers[1].username}`} className="text-campfire-light hover:text-campfire-amber font-medium">
{topUsers[1].username}
</Link>
<span className="text-campfire-ash text-sm">{topUsers[1].review_count} рецензий</span>
</motion.div>
)}
{/* First Place */}
{topUsers[0] && (
<motion.div
className="flex flex-col items-center w-1/3"
variants={itemVariants}
>
<div className="relative">
<div className="w-32 h-32 rounded-full overflow-hidden border-2 border-campfire-amber mb-2">
<img
src={topUsers[0].profile_picture ? getFileUrl(topUsers[0], 'profile_picture') : 'https://via.placeholder.com/150'}
alt={topUsers[0].username}
className="w-full h-full object-cover"
/>
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-campfire-amber rounded-full flex items-center justify-center">
<span className="text-campfire-dark font-bold">1</span>
</div>
</div>
<Link to={`/profile/${topUsers[0].username}`} className="text-campfire-light hover:text-campfire-amber font-medium">
{topUsers[0].username}
</Link>
<span className="text-campfire-ash text-sm">{topUsers[0].review_count} рецензий</span>
</motion.div>
)}
{/* Third Place */}
{topUsers[2] && (
<motion.div
className="flex flex-col items-center w-1/3"
variants={itemVariants}
>
<div className="relative">
<div className="w-24 h-24 rounded-full overflow-hidden border-2 border-campfire-ember/30 mb-2">
<img
src={topUsers[2].profile_picture ? getFileUrl(topUsers[2], 'profile_picture') : 'https://via.placeholder.com/150'}
alt={topUsers[2].username}
className="w-full h-full object-cover"
/>
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-campfire-ember/30 rounded-full flex items-center justify-center">
<span className="text-campfire-light font-bold">3</span>
</div>
</div>
<Link to={`/profile/${topUsers[2].username}`} className="text-campfire-light hover:text-campfire-amber font-medium">
{topUsers[2].username}
</Link>
<span className="text-campfire-ash text-sm">{topUsers[2].review_count} рецензий</span>
</motion.div>
)}
</div>
<Link to="/rating" className="text-campfire-amber hover:underline mt-4">
Посмотреть полный рейтинг
</Link>
</div>
) : (
<p className="text-campfire-ash text-center py-8">Нет данных о топ рецензентах.</p>
)}
</motion.div>
{/* Latest Added Section */}
<motion.div variants={itemVariants}>
<h2 className="text-2xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4">Последнее добавленное</h2>
{latestAddedMedia.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-8 md:gap-20">
{latestAddedMedia.map((mediaItem) => (
<motion.div
key={mediaItem.id}
variants={itemVariants}
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
>
<Link
to={`/media/${mediaItem.path}`}
className="block group"
>
<TiltedCard
imageSrc={mediaItem.poster ? getFileUrl(mediaItem, 'poster', { thumb: '300x450' }) : 'https://via.placeholder.com/300x450'}
altText={mediaItem.title}
captionText={mediaItem.title}
containerHeight="360px"
containerWidth="100%"
imageHeight="360px"
imageWidth="240px"
scaleOnHover={1.05}
rotateAmplitude={10}
showMobileWarning={false}
showTooltip={false}
displayOverlayContent={false}
rating={mediaItem.average_rating}
releaseDate={mediaItem.release_date}
/>
</Link>
</motion.div>
))}
</div>
) : (
<p className="text-campfire-ash text-center py-8">Нет данных о последнем добавленном медиа.</p>
)}
</motion.div>
</div>
</motion.div>
</div>
<motion.div variants={itemVariants}>
<FeaturedMedia media={featuredMedia} />
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
export default HomePage;

154
src/pages/LoginPage.jsx Normal file
View File

@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; // Import useAuth
const LoginPage = () => {
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { user, userProfile, loading: authLoading, signIn, isInitialized } = useAuth(); // Get isInitialized
console.log('LoginPage: Rendering. State:', { user: !!user, userProfile: !!userProfile, authLoading, isInitialized, componentLoading: loading, componentError: error }); // Add component render log
useEffect(() => {
console.log('LoginPage useEffect: Running. State:', { user: !!user, userProfile: !!userProfile, authLoading, isInitialized }); // Add useEffect log
if (!authLoading && isInitialized) { // Wait for auth to be initialized and not loading
if (user && userProfile) {
console.log('LoginPage useEffect: User and profile loaded, redirecting to profile:', userProfile.username);
navigate(`/profile/${userProfile.username}`);
} else {
console.log('LoginPage useEffect: No user and auth initialized, staying on login.');
}
} else if (authLoading) {
console.log('LoginPage useEffect: Auth is loading...');
} else if (!isInitialized) {
console.log('LoginPage useEffect: Auth is not initialized...');
}
}, [user, userProfile, authLoading, isInitialized, navigate]); // Add isInitialized to dependencies
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await signIn(login, password);
navigate('/');
} catch (err) {
console.error('Login error:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
// Render loading state based on AuthContext loading
// Removed the check here to allow the form to render even if auth is loading,
// relying on the AuthProvider's own loading state handling.
// This might help diagnose if the component itself is not rendering.
// if (authLoading || !isInitialized) {
// console.log('LoginPage: Rendering initial auth loading state.');
// return (
// <div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark text-campfire-light">
// Загрузка авторизации...
// </div>
// );
// }
console.log('LoginPage: Rendering login form.'); // Log before rendering form
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="w-full max-w-md p-8 bg-campfire-charcoal rounded-lg shadow-lg">
<h1 className="text-2xl font-bold text-campfire-light mb-6 text-center">Вход</h1>
{error && (
<div className="mb-4 p-3 bg-status-error/20 text-status-error rounded-lg text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="login" className="block text-sm font-medium text-campfire-light mb-1">
Логин
</label>
<input
type="text"
id="login"
value={login}
onChange={(e) => setLogin(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="Введите логин"
required
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-campfire-light mb-1">
Пароль
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={loading}
className={`btn-primary w-full ${loading ? "opacity-80 cursor-not-allowed" : ""} transition-colors duration-200`}
>
{loading ? (
<span className="inline-flex items-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Входим...
</span>
) : (
"Войти"
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-campfire-ash">
Еще нет аккаунта?{" "}
<Link
to="/auth/register"
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors"
>
Зарегистрироваться
</Link>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@ -0,0 +1,585 @@
import React, { useEffect, useState, useCallback } from 'react'; // Import useCallback
import { useParams } from 'react-router-dom';
import {
getMediaByPath, // Use getMediaByPath
getReviewsByMediaId,
createReview,
updateReview,
deleteReview,
getUserReviewForMedia,
getFileUrl, // Import getFileUrl
updateMedia, // Import updateMedia for editing
getSeasonsByMediaId, // Import getSeasonsByMediaId
getSeasonById // Import getSeasonById
} from '../services/pocketbaseService';
import { useAuth } from '../contexts/AuthContext';
import ReviewForm from '../components/reviews/ReviewForm';
import ReviewList from '../components/reviews/ReviewList';
import RatingChart from '../components/reviews/RatingChart';
import { FaFire, FaEdit } from 'react-icons/fa'; // Import FaEdit icon
import Modal from '../components/common/Modal'; // Import Modal
import MediaForm from '../components/admin/MediaForm'; // Import MediaForm
const MediaOverviewPage = () => { // Renamed component
// Get the 'path' parameter from the URL
const { path } = useParams();
const { user, userProfile } = useAuth(); // Get user and userProfile for permissions
const [media, setMedia] = useState(null);
const [reviews, setReviews] = useState([]);
const [userReview, setUserReview] = useState(null); // State for the current user's review
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Initialize reviewCharacteristics from media.characteristics or use a default
const [reviewCharacteristics, setReviewCharacteristics] = useState({});
const [isEditModalOpen, setIsEditModalOpen] = useState(false); // State for edit modal
// New state for seasons
const [seasons, setSeasons] = useState([]);
// State to track which season's reviews are currently displayed (null for overall)
const [selectedSeasonId, setSelectedSeasonId] = useState(null);
// New state for the currently selected season's details
const [selectedSeasonDetails, setSelectedSeasonDetails] = useState(null);
// Check if the current user is admin or critic
const isAdminOrCritic = userProfile && (userProfile.role === 'admin' || userProfile.is_critic === true);
// Determine if the media type supports seasons
const supportsSeasons = media?.type === 'tv' || media?.type === 'anime';
// Use useCallback for loadMediaAndSeasons
const loadMediaAndSeasons = useCallback(async () => {
try {
setLoading(true); // Start loading for media/seasons
setError(null);
console.log('MediaOverviewPage: Effect 1 - Loading media for path:', path); // Renamed log
const mediaData = await getMediaByPath(path);
if (!mediaData) {
throw new Error('Медиа не найдено');
}
setMedia(mediaData);
console.log('MediaOverviewPage: Effect 1 - Media data loaded and set to state:', mediaData); // Renamed log
// Use characteristics from the fetched media data, or a default if not available
const characteristics = mediaData.characteristics && typeof mediaData.characteristics === 'object'
? mediaData.characteristics
: { overall: 'Общая оценка' }; // Fallback default
setReviewCharacteristics(characteristics);
// If media supports seasons, fetch seasons
if (mediaData.type === 'tv' || mediaData.type === 'anime') {
const seasonsData = await getSeasonsByMediaId(mediaData.id);
// Sort seasons by season_number
seasonsData.sort((a, b) => a.season_number - b.season_number);
setSeasons(seasonsData || []);
console.log('MediaOverviewPage: Effect 1 - Seasons loaded:', seasonsData); // Renamed log
} else {
setSeasons([]); // Clear seasons if media type doesn't support them
}
// Set initial selected season to null (overall reviews)
// This will trigger Effect 2 to load overall reviews
setSelectedSeasonId(null); // Reset selected season on media change
setSelectedSeasonDetails(null); // Clear season details
} catch (err) {
console.error("Error loading media or seasons:", err);
setError("Не удалось загрузить контент или сезоны");
setLoading(false); // Ensure loading is false on error
}
}, [path]); // Depend on path to refetch if path changes
// Effect 1: Load media and seasons when path changes
useEffect(() => {
if (path) {
loadMediaAndSeasons();
}
}, [path, loadMediaAndSeasons]); // Depend on path and the memoized loadMediaAndSeasons
// Function to load reviews
const loadReviews = useCallback(async () => {
if (!media?.id) return; // Don't proceed if media is not loaded
try {
setError(null); // Clear previous errors
console.log('MediaOverviewPage: Effect 2 - Loading reviews for media ID:', media.id, 'and season ID:', selectedSeasonId); // Renamed log
console.log('MediaOverviewPage: Effect 2 - Current user ID:', user?.id); // Renamed log // Log current user ID
// Если выбран сезон, загружаем рецензии только для него
// Если выбран "Общее", загружаем все рецензии для всех сезонов
const reviewsData = selectedSeasonId
? await getReviewsByMediaId(media.id, selectedSeasonId)
: await getReviewsByMediaId(media.id, null, true); // Добавляем параметр для загрузки всех рецензий
setReviews(reviewsData || []);
console.log('MediaOverviewPage: Effect 2 - Reviews loaded:', reviewsData); // Renamed log
// Fetch the current user's review if logged in, for the selected media/season
if (user) {
console.log('MediaOverviewPage: Effect 2 - Fetching user review for user ID:', user.id, 'media ID:', media.id, 'and season ID:', selectedSeasonId); // Renamed log
const userReviewData = await getUserReviewForMedia(user.id, media.id, selectedSeasonId);
setUserReview(userReviewData);
console.log('MediaOverviewPage: Effect 2 - User review fetched:', userReviewData); // Renamed log
} else {
setUserReview(null); // Ensure userReview is null if not logged in
console.log('MediaOverviewPage: Effect 2 - User not logged in, skipping user review fetch.'); // Renamed log
}
} catch (err) {
console.error("Error loading reviews:", err);
setError("Не удалось загрузить рецензии");
} finally {
// Set loading false only if media is also loaded (handled by Effect 1)
// Ensure loading is false after reviews are loaded, regardless of media loading state
setLoading(false);
}
}, [media?.id, selectedSeasonId, user]); // Depend on media.id, selectedSeasonId, and user
// Effect 2: Load reviews when media ID or selectedSeasonId changes
useEffect(() => {
loadReviews();
}, [media?.id, selectedSeasonId, user, loadReviews]); // Depend on media.id, selectedSeasonId, user, and the memoized loadReviews
// Effect 3: Load selected season details when selectedSeasonId changes
useEffect(() => {
const loadSeasonDetails = async () => {
if (selectedSeasonId) {
try {
console.log('MediaOverviewPage: Effect 3 - Loading season details for ID:', selectedSeasonId); // Renamed log
const seasonData = await getSeasonById(selectedSeasonId);
setSelectedSeasonDetails(seasonData);
console.log('MediaOverviewPage: Effect 3 - Season details loaded:', seasonData); // Renamed log
} catch (err) {
console.error("Error loading season details:", err);
setSelectedSeasonDetails(null); // Clear details on error
}
} else {
setSelectedSeasonDetails(null); // Clear details if overall is selected
}
};
loadSeasonDetails();
}, [selectedSeasonId]); // Depend on selectedSeasonId
// Log media state whenever it changes
useEffect(() => {
console.log('MediaOverviewPage: Media state updated:', media); // Renamed log
if (media) {
console.log('MediaOverviewPage: Media state stats:', { average_rating: media.average_rating, review_count: media.review_count }); // Renamed log
}
}, [media]);
// Function to reload reviews and user review after an action (create, edit, delete)
const reloadReviewsAndUserReview = useCallback(async () => {
if (!media?.id) return;
try {
console.log('MediaOverviewPage: Reloading reviews after action...'); // Renamed log
const reviewsData = await getReviewsByMediaId(media.id, selectedSeasonId);
setReviews(reviewsData || []);
if (user) {
const userReviewData = await getUserReviewForMedia(user.id, media.id, selectedSeasonId);
setUserReview(userReviewData);
} else {
setUserReview(null);
}
// Also reload media data to get updated overall stats
const updatedMediaData = await getMediaByPath(path);
if (updatedMediaData) {
setMedia(updatedMediaData);
// No need to update characteristics here, they are static per media
}
} catch (err) {
console.error("Error reloading data after action:", err);
}
}, [media?.id, selectedSeasonId, user, path]); // Depend on media.id, selectedSeasonId, user, and path
const handleReviewSubmit = async (reviewData) => {
try {
// Use the media ID from the state and the season ID from the form (reviewData.season_id)
// The createReview function now adds the user_id internally
await createReview({ ...reviewData, media_id: media.id }); // Pass media_id explicitly
console.log('MediaOverviewPage: Review submitted successfully. Reloading data...'); // Renamed log
// Refresh data after submission
setTimeout(() => {
reloadReviewsAndUserReview();
}, 300); // 300ms delay
} catch (error) {
console.error('Error submitting review:', error);
// Handle error (e.g., show a message to the user)
}
};
const handleReviewEdit = async (reviewId, reviewData) => {
try {
// Use the media ID from the state and the season ID from the form (reviewData.season_id)
await updateReview(reviewId, { ...reviewData, media_id: media.id }); // Pass media_id explicitly
console.log('MediaOverviewPage: Review edited successfully. Reloading data...'); // Renamed log
// Refresh data after edit
setTimeout(() => {
reloadReviewsAndUserReview();
}, 300); // 300ms delay
} catch (error) {
console.error('Error editing review:', error);
// Handle error
}
};
const handleReviewDelete = async (reviewId) => {
try {
// Use the media ID from the state
await deleteReview(reviewId, media.id); // deleteReview only needs reviewId, mediaId is optional
console.log('MediaOverviewPage: Review deleted successfully. Reloading data...'); // Renamed log
// Refresh data after delete
setTimeout(() => {
reloadReviewsAndUserReview();
}, 300); // 300ms delay
} catch (error) {
console.error('Error deleting review:', error);
// Handle error
}
};
// Handle media edit submission from the modal
const handleMediaEditSubmit = async (mediaData) => {
if (!media) return; // Should not happen if modal is opened with media loaded
try {
// updateMedia expects FormData
const formData = new FormData();
// Append all fields from mediaData object
Object.keys(mediaData).forEach(key => {
// Handle files separately if needed, but MediaForm should return FormData
// If MediaForm returns a plain object, you'll need to convert it to FormData here
// Assuming MediaForm returns FormData directly:
if (mediaData[key] instanceof FileList) {
// Handle FileList - append each file
for (let i = 0; i < mediaData[key].length; i++) {
formData.append(key, mediaData[key][i]);
}
} else if (mediaData[key] instanceof File) {
formData.append(key, mediaData[key]);
}
else if (mediaData[key] !== null && mediaData[key] !== undefined) {
// Convert non-file values to string, especially booleans or numbers
formData.append(key, String(mediaData[key]));
} else {
// Handle null/undefined fields if needed (e.g., to clear a field)
// PocketBase handles null for file fields to delete them
formData.append(key, ''); // Append empty string for null/undefined non-file fields
}
});
console.log('MediaOverviewPage: Submitting media edit for ID:', media.id, 'with data:', mediaData); // Renamed log
// Call the updateMedia service function
await updateMedia(media.id, formData);
console.log('MediaOverviewPage: Media updated successfully. Reloading data...'); // Renamed log
setIsEditModalOpen(false); // Close modal
// Reload page data to show updated media details
// Reloading media will trigger the effects to reload seasons and reviews
loadMediaAndSeasons(); // Trigger full reload
} catch (error) {
console.error('MediaOverviewPage: Error updating media:', error); // Renamed log
// Handle error (e.g., show message in modal)
throw error; // Re-throw to allow MediaForm to handle error state
}
};
if (loading) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
if (!media) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="text-campfire-ash text-lg">Медиа не найдено.</div>
</div>
);
}
// Calculate average ratings for the chart from all reviews
// This calculation is still needed for the detailed characteristic chart
const aggregateRatings = reviews.reduce((acc, review) => {
// Use review.ratings if available, otherwise fallback to overall_rating
// Now review.ratings is expected to be { [key]: number }
const ratingsToAggregate = review.ratings && typeof review.ratings === 'object' && Object.keys(review.ratings).length > 0
? review.ratings
: { overall: review.overall_rating }; // Adapt fallback
Object.entries(ratingsToAggregate).forEach(([key, ratingValue]) => {
// Only include numeric values that correspond to a characteristic key
if (reviewCharacteristics.hasOwnProperty(key) && typeof ratingValue === 'number' && ratingValue >= 1 && ratingValue <= 10) {
if (!acc[key]) {
acc[key] = { total: 0, count: 0 };
}
acc[key].total += ratingValue;
acc[key].count += 1;
} else {
// console.warn(`Invalid rating data found for key "${key}" in review ${review.id}:`, ratingValue);
}
});
return acc;
}, {});
const averageRatingsForChart = Object.entries(aggregateRatings).reduce((acc, [key, data]) => {
// Only include characteristics that are defined in the media's characteristics
if (reviewCharacteristics[key]) {
acc[key] = data.count > 0 ? parseFloat((data.total / data.count).toFixed(2)) : 0; // Calculate average and fix to 2 decimal places
}
return acc;
}, {});
console.log('MediaOverviewPage: Rendering. Current media state:', media); // Renamed log // Log state before render
console.log('MediaOverviewPage: Rendering. Current selectedSeasonDetails state:', selectedSeasonDetails); // Renamed log // Log season details state
return (
<div className="min-h-screen bg-campfire-dark pt-0"> {/* Changed pt-20 to pt-0 */}
{/* Hero Section */}
<div
className="relative h-96 bg-cover bg-center"
// Use getFileUrl for backdrop field
style={{ backgroundImage: `url(${getFileUrl(media, 'backdrop')})` }}
>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark to-transparent opacity-90"></div>
<div className="container-custom relative z-10 flex items-end h-full pb-12">
<div className="flex items-center">
<img
// Use getFileUrl for poster field
src={getFileUrl(media, 'poster')}
alt={media.title}
className="w-32 md:w-48 rounded-lg shadow-xl mr-8 object-cover"
/>
<div>
<h1 className="text-4xl md:text-5xl font-bold text-campfire-light mb-4">
{media.title}
</h1>
{/* Updated rating, star, review count, and added release date */}
<div className="flex items-center text-campfire-ash text-lg mb-4">
{/* Larger Rating */}
<span className="text-campfire-amber mr-4 text-2xl font-bold"> {/* Increased size and weight */}
{media.average_rating !== null && media.average_rating !== undefined && !isNaN(parseFloat(media.average_rating)) ? parseFloat(media.average_rating).toFixed(1) : 'N/A'} / 10
</span>
{/* Star Icon */}
<FaFire className="text-campfire-amber mr-4 text-xl" /> {/* Adjusted icon size */}
{/* Review Count */}
<span className="text-campfire-ash text-lg mr-4"> {/* Added mr-4 for spacing */}
{media.review_count !== null && media.review_count !== undefined && !isNaN(parseInt(media.review_count)) ? parseInt(media.review_count) : 0} рецензий
</span>
{/* Release Date */}
{media.release_date && ( // Only show if release_date exists
<span className="text-campfire-ash text-lg">
Дата выхода: {new Date(media.release_date).toLocaleDateString()}
</span>
)}
</div>
{/* REMOVED: Duplicate overview text from hero section */}
{/* <p className="text-campfire-ash text-lg max-w-2xl line-clamp-3">
{media.overview || media.description}
</p> */}
</div>
</div>
</div>
</div>
<div className="container-custom py-12">
{/* Overview and Details */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-campfire-light mb-4">Обзор</h2>
<p className="text-campfire-ash leading-relaxed">
{media.overview || media.description || 'Описание отсутствует.'}
</p>
{/* Add more details here if available in media object */}
{/* <div className="mt-6 text-campfire-ash">
<p><strong>Тип:</strong> {media.type === 'movie' ? 'Фильм' : media.type === 'tv' ? 'Сериал' : 'Игра'}</p>
<p><strong>Дата выхода:</strong> {new Date(media.release_date).toLocaleDateString()}</p>
</div> */}
</div>
{/* Edit Media Button (Visible only to Admin/Critic) */}
{isAdminOrCritic && media && (
<div className="flex justify-end mb-8"> {/* Added margin bottom */}
<button
onClick={() => setIsEditModalOpen(true)}
className="btn-secondary flex items-center space-x-2"
>
<FaEdit size={18} />
<span>Редактировать медиа</span>
</button>
</div>
)}
{/* Seasons Navigation (if media supports seasons and has seasons) */}
{supportsSeasons && seasons.length > 0 && (
<div className="mb-8">
<h3 className="text-xl font-bold text-campfire-light mb-4">Сезоны</h3>
<div className="flex flex-wrap gap-2">
{/* Button for overall reviews */}
<button
onClick={() => setSelectedSeasonId(null)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
selectedSeasonId === null
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal text-campfire-ash hover:bg-campfire-ash/20'
}`}
>
Общее
</button>
{/* Buttons for each season */}
{seasons.map(season => (
<button
key={season.id}
onClick={() => setSelectedSeasonId(season.id)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
selectedSeasonId === season.id
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal text-campfire-ash hover:bg-campfire-ash/20'
}`}
>
Сезон {season.season_number} {season.title ? ` - ${season.title}` : ''}
</button>
))}
</div>
</div>
)}
{/* Selected Season Details (if a season is selected) */}
{selectedSeasonDetails && (
<div className="mb-12 p-6 bg-campfire-charcoal rounded-lg shadow-md border border-campfire-ash/20">
<h3 className="text-2xl font-bold text-campfire-light mb-4">
Сезон {selectedSeasonDetails.season_number} {selectedSeasonDetails.title ? `- ${selectedSeasonDetails.title}` : ''}
</h3>
<div className="flex items-start">
{selectedSeasonDetails.poster && (
<img
src={getFileUrl(selectedSeasonDetails, 'poster')}
alt={`Постер сезона ${selectedSeasonDetails.season_number}`}
className="w-24 md:w-32 rounded-md shadow-lg mr-6 object-cover border border-campfire-ash/30"
/>
)}
<div>
{selectedSeasonDetails.overview && (
<p className="text-campfire-ash leading-relaxed mb-4">
{selectedSeasonDetails.overview}
</p>
)}
{selectedSeasonDetails.release_date && (
<p className="text-campfire-ash text-sm">
Дата выхода: {new Date(selectedSeasonDetails.release_date).toLocaleDateString()}
</p>
)}
{/* Display season rating and review count */}
<div className="flex items-center text-campfire-ash text-sm mt-2">
<span className="text-campfire-amber mr-2 font-bold">
{selectedSeasonDetails.average_rating !== null && selectedSeasonDetails.average_rating !== undefined && !isNaN(parseFloat(selectedSeasonDetails.average_rating)) ? parseFloat(selectedSeasonDetails.average_rating).toFixed(1) : 'N/A'} / 10
</span>
<FaFire className="text-campfire-amber mr-2 text-base" />
<span>
{selectedSeasonDetails.review_count !== null && selectedSeasonDetails.review_count !== undefined && !isNaN(parseInt(selectedSeasonDetails.review_count)) ? parseInt(selectedSeasonDetails.review_count) : 0} рецензий
</span>
</div>
</div>
</div>
</div>
)}
{/* Review Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4">
Рецензии {selectedSeasonId === null ? ' (Общие)' : ` (Сезон ${seasons.find(s => s.id === selectedSeasonId)?.season_number || ''})`}
</h2>
{/* Average Rating Chart */}
{reviews.length > 0 && Object.keys(averageRatingsForChart).length > 0 && ( // Only show chart if there are reviews and applicable ratings
<div className="mb-8 bg-campfire-charcoal rounded-lg shadow-md p-6">
<h3 className="text-xl font-bold text-campfire-light mb-4 text-center">Средние оценки по характеристикам</h3>
<div className="max-w-md mx-auto">
{/* Pass calculated average ratings and characteristics labels */}
<RatingChart ratings={averageRatingsForChart} labels={reviewCharacteristics} size="large" />
</div>
</div>
)}
{/* Review Form (Create/Edit/Delete) */}
{user ? (
<ReviewForm
mediaId={media.id} // Pass the media ID
seasonId={selectedSeasonId} // Pass the selected season ID
mediaType={media.type} // Pass the media type
progressType={media.progress_type} // Pass the media's progress type
characteristics={reviewCharacteristics} // Pass dynamic characteristics
onSubmit={handleReviewSubmit}
onEdit={handleReviewEdit}
onDelete={handleReviewDelete}
existingReview={userReview} // Pass the user's existing review
seasons={seasons} // Pass seasons to the form for season selection
selectedSeasonId={selectedSeasonId} // Pass selected season ID to form
/>
) : (
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6 text-center">
<p className="text-campfire-light">
<a href="/auth" className="text-campfire-amber hover:underline">Войдите</a>, чтобы оставить рецензию.
</p>
</div>
)}
{/* Review List */}
{/* Pass reviewCharacteristics to ReviewList */}
<ReviewList reviews={reviews} reviewCharacteristics={reviewCharacteristics} />
</div>
</div>
{/* Edit Media Modal */}
{media && ( // Only render modal if media data is loaded
<Modal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
title={`Редактировать: ${media.title}`}
size="lg" // Use large size for the modal
>
{/* Pass the current media object to MediaForm for editing */}
<MediaForm
media={media} // Pass the media object to pre-fill the form
onSuccess={handleMediaEditSubmit} // Handle the update logic
onCancel={() => setIsEditModalOpen(false)} // Close modal on cancel
isEditing={true} // Indicate that the form is in editing mode
/>
</Modal>
)}
</div>
);
};
export default MediaOverviewPage; // Renamed export

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { FaHome } from 'react-icons/fa';
import FuzzyText from '../components/reactbits/TextAnimations/FuzzyText/FuzzyText';
import notFoundImage from '../assets/404.webp';
const NotFoundPage = () => {
return (
<div className="min-h-screen bg-campfire-dark flex flex-col items-center justify-center p-4">
<div className="text-center">
<img
src={notFoundImage}
alt="404"
className="w-64 h-64 mb-8 mx-auto"
/>
<FuzzyText
fontSize="clamp(4rem, 15vw, 12rem)"
fontWeight={900}
color="#FFA500"
baseIntensity={0.2}
hoverIntensity={0.6}
>
404
</FuzzyText>
<p className="text-campfire-light text-xl mt-4 mb-8">
Страница не найдена
</p>
<Link
to="/"
className="inline-flex items-center px-6 py-3 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/90 transition-colors duration-200"
>
<FaHome className="mr-2" />
окак
</Link>
</div>
</div>
);
};
export default NotFoundPage;

896
src/pages/ProfilePage.jsx Normal file
View File

@ -0,0 +1,896 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; // Import useNavigate
import {
pb, // Import pb instance
getUserProfileByUsername,
getUserAchievements,
getReviewsByUserId,
getFileUrl,
updateUserProfile, // Note: This is not used for file uploads/deletions in this component
updateUserShowcase,
getXpForCurrentLevel,
getXpForNextLevel,
getAllAchievements, // Import getAllAchievements
awardAchievement // Import awardAchievement
} from '../services/pocketbaseService';
import { useAuth } from '../contexts/AuthContext';
import { useProfileActions } from '../contexts/ProfileActionsContext';
import ReviewItem from '../components/reviews/ReviewItem';
import ReviewList from '../components/reviews/ReviewList';
import Modal from '../components/common/Modal';
import { FaEdit, FaCheckCircle, FaRegCircle, FaFire, FaTrophy, FaAward } from 'react-icons/fa'; // Added FaAward
import { mediaTypes } from '../services/pocketbaseService';
const ProfilePage = () => {
const { username } = useParams();
const { user: currentUser, userProfile: currentUserProfile, loading: authLoading, isInitialized: authInitialized } = useAuth(); // currentUser is the PocketBase auth model
const { shouldOpenEditModal, resetEditModalTrigger } = useProfileActions() || {};
const navigate = useNavigate(); // Initialize useNavigate
const [profileUser, setProfileUser] = useState(null);
const [achievements, setAchievements] = useState([]);
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isEditingProfile, setIsEditingProfile] = useState(false);
const [editFormData, setEditFormData] = useState({ description: '', profile_picture: null, banner_picture: null });
const [profilePictureFile, setProfilePictureFile] = useState(null);
const [bannerPictureFile, setBannerPictureFile] = useState(null);
const [deleteProfilePicture, setDeleteProfilePicture] = useState(false);
const [deleteBannerPicture, setDeleteBannerPicture] = useState(false);
const [editFormError, setEditFormError] = useState(null);
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [isManagingShowcase, setIsManagingShowcase] = useState(false);
const [selectedShowcaseReviewIds, setSelectedShowcaseReviewIds] = useState([]);
// State for Achievement Assignment Modal (New)
const [isAssigningAchievements, setIsAssigningAchievements] = useState(false);
const [availableAchievements, setAvailableAchievements] = useState([]); // To fetch list of all achievements
const [userAchievementIds, setUserAchievementIds] = useState([]); // To track achievements the user already has
const [selectedAchievementIdsToAssign, setSelectedAchievementIdsToAssign] = useState([]); // To track achievements to assign/remove
// Determine if the logged-in user is viewing their own profile
const isOwnProfile = currentUser && profileUser && currentUser.id === profileUser.id;
// Check if the logged-in user has the 'admin' role
const isAdmin = currentUser?.role === 'admin';
const loadProfileData = useCallback(async () => {
if (!username) {
setError('Имя пользователя не указано.');
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// Fetch user profile by username, expanding showcase relation
const userData = await getUserProfileByUsername(username);
if (!userData) {
throw new Error('Пользователь не найден.');
}
setProfileUser(userData);
console.log('ProfilePage: User data loaded:', userData);
console.log('ProfilePage: User showcase data:', userData.showcase, 'expanded:', userData.expand?.showcase);
// Fetch user's achievements
const achievementsData = await getUserAchievements(userData.id);
setAchievements(achievementsData || []);
console.log('ProfilePage: Achievements loaded:', achievementsData);
// Store user's current achievement IDs for assignment modal
setUserAchievementIds(achievementsData.map(ua => ua.achievement_id) || []);
// Fetch user's reviews
const reviewsData = await getReviewsByUserId(userData.id);
setReviews(reviewsData || []);
console.log('ProfilePage: Reviews loaded:', reviewsData);
// Initialize showcase selection state for management modal
setSelectedShowcaseReviewIds(userData.showcase || []);
} catch (err) {
console.error('Error loading profile data:', err);
setError('Не удалось загрузить данные профиля.');
} finally {
setLoading(false);
}
}, [username]);
useEffect(() => {
console.log('ProfilePage mounted or dependencies changed. username:', username, 'currentUser:', !!currentUser, 'authLoading:', authLoading, 'authInitialized:', authInitialized, 'profileUser:', !!profileUser, 'isOwnProfile:', isOwnProfile); // Added profileUser and isOwnProfile to log
// Wait for auth to be initialized before loading profile data
if (!authLoading && authInitialized) {
console.log('ProfilePage useEffect: Auth initialized and not loading. Loading profile data...');
loadProfileData();
} else {
console.log('ProfilePage useEffect: Waiting for auth initialization or loading...');
}
}, [username, currentUser, authLoading, authInitialized, loadProfileData]); // Added authInitialized to dependencies
// Effect to open edit modal if triggered by context (e.g., from Header)
// This effect should ONLY handle the external trigger and the edit modal.
// It should NOT interfere with modals opened by buttons on the page itself.
useEffect(() => {
console.log('ProfilePage useEffect [shouldOpenEditModal, isOwnProfile, profileUser]: Running for external edit trigger. shouldOpenEditModal:', shouldOpenEditModal, 'isOwnProfile:', isOwnProfile, 'profileUser:', !!profileUser);
// Check if the trigger is set AND we are viewing the *own* profile AND profileUser data is loaded
if (shouldOpenEditModal && isOwnProfile && profileUser) {
console.log('ProfilePage useEffect [shouldOpenEditModal, isOwnProfile, profileUser]: Triggered to open edit modal. Opening modal...');
handleEditProfile(); // Open the modal
// Reset the trigger *after* the modal is successfully opened
resetEditModalTrigger();
} else if (shouldOpenEditModal && (!isOwnProfile || !profileUser)) {
// If trigger is set but it's not the own profile or profile data isn't ready,
// DO NOT reset the trigger immediately. Let the next render/useEffect run
// when profileUser might be loaded.
console.log('ProfilePage useEffect [shouldOpenEditModal, isOwnProfile, profileUser]: Trigger set, but not own profile or profile data not ready. Waiting for profile data...');
// Do nothing here, let the effect potentially run again when profileUser is loaded
}
// REMOVED: The else if block that closed modals when shouldOpenEditModal was false.
// Modals opened by buttons on the page should be closed by their own handlers.
}, [shouldOpenEditModal, isOwnProfile, profileUser, resetEditModalTrigger]); // Added modal states to dependencies
// --- Profile Edit Modal Handlers ---
const handleEditProfile = () => {
if (!profileUser) return;
console.log('ProfilePage: Opening edit profile modal.');
setEditFormData({
description: profileUser.description || '',
profile_picture: null,
banner_picture: null,
});
setProfilePictureFile(null);
setBannerPictureFile(null);
setDeleteProfilePicture(false);
setDeleteBannerPicture(false);
setEditFormError(null);
setIsEditingProfile(true);
};
const handleEditFormChange = (e) => {
const { name, value } = e.target;
setEditFormData(prev => ({ ...prev, [name]: value }));
};
const handleEditFileChange = (e) => {
const { name, files } = e.target;
const file = files?.[0] || null;
if (name === 'profile_picture') {
setProfilePictureFile(file);
if (file) setDeleteProfilePicture(false);
} else if (name === 'banner_picture') {
setBannerPictureFile(file);
if (file) setDeleteBannerPicture(false);
}
};
const handleRemoveExistingPicture = (fieldName) => {
if (fieldName === 'profile_picture') {
setDeleteProfilePicture(true);
setProfilePictureFile(null);
} else if (fieldName === 'banner_picture') {
setDeleteBannerPicture(true);
setBannerPictureFile(null);
}
};
const handleEditFormSubmit = async (e) => {
e.preventDefault();
if (!profileUser) return;
setIsSubmittingEdit(true);
setEditFormError(null);
try {
// Use pb.collection('users').update() with FormData
const formData = new FormData();
formData.append('description', editFormData.description);
if (profilePictureFile) {
formData.append('profile_picture', profilePictureFile);
} else if (deleteProfilePicture && profileUser.profile_picture) {
// To delete an existing file, append the field name with null
formData.append('profile_picture', null);
}
if (bannerPictureFile) {
formData.append('banner_picture', bannerPictureFile);
} else if (deleteBannerPicture && profileUser.banner_picture) {
// To delete an existing file, append the field name with null
formData.append('banner_picture', null);
}
// Use pb instance imported directly
await pb.collection('users').update(profileUser.id, formData);
console.log('Profile updated successfully.');
setIsEditingProfile(false);
// Refresh the user profile in AuthContext and on the page
// Assuming refreshUserProfile in AuthContext fetches the latest user record
// and updates the context state, which will then update profileUser state here.
// If not, we might need to manually update profileUser state or refetch.
// Let's call loadProfileData to ensure page data is fresh.
loadProfileData();
} catch (err) {
console.error('Error updating profile:', err);
// PocketBase validation errors might be in err.response.data.data
const pbErrorData = err.response?.data?.data;
let errorMessage = err.message || 'Произошла ошибка при обновлении профиля.';
if (pbErrorData) {
// Attempt to format PocketBase validation errors
errorMessage = Object.entries(pbErrorData)
.map(([field, errorInfo]) => `${field}: ${errorInfo.message}`)
.join(', ');
}
setEditFormError(errorMessage);
} finally {
setIsSubmittingEdit(false);
}
};
const handleEditFormCancel = () => {
setIsEditingProfile(false);
setEditFormError(null);
};
// --- Showcase Management Handlers ---
const handleManageShowcase = () => {
if (!profileUser) return;
console.log('ProfilePage: Opening showcase management modal.'); // Added log
setSelectedShowcaseReviewIds(profileUser.showcase || []);
setIsManagingShowcase(true);
};
const handleToggleShowcaseReview = (reviewId) => {
setSelectedShowcaseReviewIds(prev => {
const isSelected = prev.includes(reviewId);
if (isSelected) {
return prev.filter(id => id !== reviewId);
} else {
if (prev.length < 3) {
return [...prev, reviewId];
} else {
alert('Вы можете выбрать до 3 рецензий для витрины.');
return prev;
}
}
});
};
const handleSaveShowcase = async () => {
if (!profileUser) return;
setIsSubmittingEdit(true);
setEditFormError(null);
try {
// PocketBase update for relation list requires sending the full list of IDs
// Use pb instance imported directly
await pb.collection('users').update(profileUser.id, {
showcase: selectedShowcaseReviewIds
});
console.log('Showcase updated successfully.');
setIsManagingShowcase(false);
loadProfileData(); // Refresh profile data to show updated showcase
} catch (err) {
console.error('Error updating showcase:', err);
const pbErrorData = err.response?.data?.data;
let errorMessage = err.message || 'Произошла ошибка при обновлении витрины.';
if (pbErrorData) {
errorMessage = Object.entries(pbErrorData)
.map(([field, errorInfo]) => `${field}: ${errorInfo.message}`)
.join(', ');
}
setEditFormError(errorMessage);
} finally {
setIsSubmittingEdit(false);
}
};
const handleCancelShowcase = () => {
setIsManagingShowcase(false);
setEditFormError(null);
};
// --- Achievement Assignment Handlers (New) ---
const handleAssignAchievements = async () => {
if (!profileUser || !isAdmin) return; // Only admin can assign
console.log('ProfilePage: Opening achievement assignment modal.'); // Added log
setIsAssigningAchievements(true);
setEditFormError(null); // Reuse editFormError state for simplicity in modals
try {
// Fetch all available achievements
// Use getAllAchievements function from service
const allAchievements = await getAllAchievements();
setAvailableAchievements(allAchievements);
// Initialize selected achievements with the ones the user already has
// We need the IDs of the user_achievements records to potentially delete them,
// or just the achievement_id to manage assignment status.
// Let's manage assignment status by achievement_id for simplicity in the modal UI.
// The actual update will involve creating/deleting user_achievements records.
const currentUserAchievementIds = achievements.map(ua => ua.achievement_id);
setSelectedAchievementIdsToAssign(currentUserAchievementIds);
} catch (err) {
console.error('Error loading achievements for assignment:', err);
setEditFormError(err.message || 'Не удалось загрузить список достижений.');
// Close modal on error? Or show error inside? Let's show inside.
}
};
const handleToggleAchievementAssignment = (achievementId) => {
setSelectedAchievementIdsToAssign(prev => {
const isSelected = prev.includes(achievementId);
if (isSelected) {
return prev.filter(id => id !== achievementId);
} else {
return [...prev, achievementId];
}
});
};
const handleSaveAchievementAssignment = async () => {
if (!profileUser || !isAdmin) return;
setIsSubmittingEdit(true); // Reuse submitting state
setEditFormError(null);
try {
// Logic to update user_achievements collection
// Compare userAchievementIds (current) with selectedAchievementIdsToAssign (desired)
const currentIds = new Set(userAchievementIds);
const desiredIds = new Set(selectedAchievementIdsToAssign);
const idsToAdd = selectedAchievementIdsToAssign.filter(id => !currentIds.has(id));
const idsToRemove = userAchievementIds.filter(id => !desiredIds.has(id));
// Add new user_achievements records
for (const achievementId of idsToAdd) {
// Use awardAchievement function from service
await awardAchievement(profileUser.id, achievementId, currentUser.id); // Pass current user as awarded_by
}
// Remove user_achievements records
// Need to find the user_achievements record IDs for the achievements to remove
const userAchievementsToRemove = achievements.filter(ua => idsToRemove.includes(ua.achievement_id));
for (const ua of userAchievementsToRemove) {
// Use pb instance imported directly
await pb.collection('user_achievements').delete(ua.id);
}
console.log('Achievement assignment updated successfully.');
setIsAssigningAchievements(false);
loadProfileData(); // Refresh profile data to show updated achievements
} catch (err) {
console.error('Error saving achievement assignment:', err);
const pbErrorData = err.response?.data?.data;
let errorMessage = err.message || 'Произошла ошибка при сохранении достижений.';
if (pbErrorData) {
errorMessage = Object.entries(pbErrorData)
.map(([field, errorInfo]) => `${field}: ${errorInfo.message}`)
.join(', ');
}
setEditFormError(errorMessage);
} finally {
setIsSubmittingEdit(false);
}
};
const handleCancelAchievementAssignment = () => {
setIsAssigningAchievements(false);
setEditFormError(null);
};
if (loading || authLoading || !authInitialized) { // Show loading if auth is still initializing/loading
console.log('ProfilePage: Showing initial loading state. Loading:', loading, 'AuthLoading:', authLoading, 'AuthInitialized:', authInitialized);
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
console.error('ProfilePage: Showing error state:', error);
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
if (!profileUser) {
console.log('ProfilePage: Showing user not found state.');
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="text-campfire-ash text-lg">Профиль пользователя не найден.</div>
</div>
);
}
const currentLevel = profileUser.level || 1;
const currentXP = profileUser.xp || 0;
const xpForCurrentLevel = getXpForCurrentLevel(currentLevel);
const xpForNextLevel = getXpForNextLevel(currentLevel);
const xpProgress = xpForNextLevel > xpForCurrentLevel ?
((currentXP - xpForCurrentLevel) / (xpForNextLevel - xpForCurrentLevel)) * 100
: 100;
const tabs = [
{ id: 'reviews', label: 'Рецензии' },
{ id: 'ratings', label: 'Оценки' },
{ id: 'lists', label: 'Списки' }
];
return (
<div className="min-h-screen bg-campfire-dark pt-0">
{/* Banner Section */}
<div
className="relative h-64 bg-cover bg-center"
style={{ backgroundImage: `url(${getFileUrl(profileUser, 'banner_picture') || 'https://images.pexels.com/photos/1103970/pexels-photo-1103970.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'})` }}
>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark to-transparent opacity-90"></div>
<div className="container-custom relative z-10 flex items-end h-full pb-8">
<div className="flex items-center w-full">
<img
src={getFileUrl(profileUser, 'profile_picture') || 'https://images.pexels.com/photos/1704488/pexels-photo-1704488.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'}
alt={profileUser.username}
className="w-24 h-24 md:w-32 md:h-32 rounded-full border-4 border-campfire-dark shadow-xl object-cover"
/>
<div className="ml-6 flex-grow">
<h1 className="text-3xl md:text-4xl font-bold text-campfire-light mb-2">
{profileUser.username}
</h1>
{/* User Stats */}
<div className="flex items-center text-campfire-ash text-sm md:text-base">
<span className="mr-4">Уровень: <b className="text-campfire-amber">{profileUser.level || 1}</b></span>
<span className="mr-4">Рецензий: <b className="text-campfire-amber">{profileUser.review_count || 0}</b></span>
<span>Средняя оценка: <b className="text-campfire-amber">{profileUser.average_rating !== null && profileUser.average_rating !== undefined ? profileUser.average_rating.toFixed(1) : 'N/A'}</b></span>
</div>
{/* XP Progress Bar */}
<div className="w-full bg-campfire-ash/30 rounded-full h-2.5 mt-2">
<div
className="bg-campfire-amber h-2.5 rounded-full transition-all duration-500 ease-out"
style={{ width: `${xpProgress}%` }}
></div>
</div>
<p className="text-campfire-ash text-xs mt-1">
XP: {profileUser.xp || 0} / {xpForNextLevel} (до Уровня {currentLevel + 1})
</p>
</div>
</div>
</div>
</div>
<div className="container-custom py-12">
{/* Description */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-campfire-light mb-4">О себе</h2>
<p className="text-campfire-ash leading-relaxed">
{profileUser.description || 'Пользователь пока ничего не рассказал о себе.'}
</p>
</div>
{/* Showcase Section */}
<div className="mb-12">
<div className="flex justify-between items-center mb-6 border-b border-campfire-ash/20 pb-4">
<h2 className="text-2xl font-bold text-campfire-light">Витрина рецензий</h2>
{isOwnProfile && (
<button
onClick={handleManageShowcase}
className="btn-secondary flex items-center space-x-2"
>
<FaEdit size={18} />
<span>Управлять витриной</span>
</button>
)}
</div>
{profileUser.expand?.showcase && profileUser.expand.showcase.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{profileUser.expand.showcase.map(review => (
<ReviewItem
key={review.id}
review={review}
media={review.expand?.media_id}
season={review.expand?.season_id}
reviewCharacteristics={review.expand?.media_id?.characteristics || {}}
isProfilePage={true}
/>
))}
</div>
) : (
<div className="text-center py-8 text-campfire-light">
{isOwnProfile ? (
<>
Ваша витрина пуста. <button onClick={handleManageShowcase} className="text-campfire-amber hover:underline">Добавьте</button> сюда свои лучшие рецензии!
</>
) : (
'У пользователя пока нет рецензий на витрине.'
)}
</div>
)}
</div>
{/* Achievements Section */}
<div className="mb-12">
<div className="flex justify-between items-center mb-6 border-b border-campfire-ash/20 pb-4">
<h2 className="text-2xl font-bold text-campfire-light">Достижения</h2>
{/* Assign Achievements Button (only for admins, visible on any profile) */}
{isAdmin && ( // Use isAdmin check
<button
onClick={handleAssignAchievements} // New handler
className="btn-secondary flex items-center space-x-2"
>
<FaAward size={18} /> {/* Changed icon */}
<span>Назначить достижения</span> {/* Changed text */}
</button>
)}
</div>
{achievements.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{achievements.map(ua => (
<div key={ua.id} className="bg-campfire-charcoal rounded-lg shadow-md p-4 flex items-center space-x-4 border border-campfire-amber/30">
{ua.expand?.achievement_id ? (
<>
<div className="flex-shrink-0 text-campfire-amber">
<FaTrophy size={32} />
</div>
<div>
<h3 className="text-lg font-semibold text-campfire-light">{ua.expand.achievement_id.name}</h3>
<p className="text-campfire-ash text-sm">{ua.expand.achievement_id.description}</p>
{ua.expand.achievement_id.xp_reward > 0 && (
<p className="text-campfire-light text-xs mt-1 flex items-center">
<FaFire className="mr-1 text-campfire-amber" /> +{ua.expand.achievement_id.xp_reward} XP
</p>
)}
</div>
</>
) : (
<p className="text-campfire-ash">Достижение (ID: {ua.achievement_id})</p>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8 text-campfire-light">
У пользователя пока нет достижений.
</div>
)}
</div>
{/* All Reviews Section - Render as smaller cards */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4">Все рецензии</h2>
{reviews.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> {/* Use grid for smaller cards */}
{reviews.map(review => (
<ReviewItem
key={review.id}
review={review}
media={review.expand?.media_id}
season={review.expand?.season_id}
reviewCharacteristics={review.expand?.media_id?.characteristics || {}}
isProfilePage={true} // Indicate it's on the profile page
isSmallCard={true} // Indicate it should render as a small card
/>
))}
</div>
) : (
<div className="text-center py-8 text-campfire-light">
У пользователя пока нет рецензий.
</div>
)}
</div>
</div>
{/* Profile Edit Modal */}
{profileUser && (
<Modal
isOpen={isEditingProfile}
onClose={handleEditFormCancel}
title={`Редактировать профиль: ${profileUser.username}`}
size="lg"
>
<form onSubmit={handleEditFormSubmit} className="space-y-6">
{editFormError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
Ошибка: {editFormError}
</div>
)}
{/* Description */}
<div>
<label htmlFor="description" className="block mb-2 text-campfire-light text-sm font-medium">
О себе (опционально)
</label>
<textarea
id="description"
name="description"
rows="4"
value={editFormData.description}
onChange={handleEditFormChange}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
/>
</div>
{/* Profile Picture */}
<div>
<label htmlFor="profile_picture" className="block mb-2 text-campfire-light text-sm font-medium">
Фото профиля (опционально)
</label>
{profileUser.profile_picture && !deleteProfilePicture && (
<div className="mb-4">
<p className="text-sm text-campfire-ash mb-2">Текущее фото:</p>
<img src={getFileUrl(profileUser, 'profile_picture')} alt="Current Profile" className="w-20 h-20 rounded-full mb-2 object-cover" />
<button
type="button"
onClick={() => handleRemoveExistingPicture('profile_picture')}
className="text-red-500 hover:underline text-sm"
>
Удалить текущее фото
</button>
</div>
)}
<input
type="file"
id="profile_picture"
name="profile_picture"
onChange={handleEditFileChange}
className="w-full text-campfire-light text-sm
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-campfire-amber file:text-campfire-dark
hover:file:bg-campfire-amber/80 cursor-pointer"
/>
</div>
{/* Banner Picture */}
<div>
<label htmlFor="banner_picture" className="block mb-2 text-campfire-light text-sm font-medium">
Баннер профиля (опционально)
</label>
{profileUser.banner_picture && !deleteBannerPicture && (
<div className="mb-4">
<p className="text-sm text-campfire-ash mb-2">Текущий баннер:</p>
<img src={getFileUrl(profileUser, 'banner_picture')} alt="Current Banner" className="w-full h-32 object-cover rounded-md mb-2" />
<button
type="button"
onClick={() => handleRemoveExistingPicture('banner_picture')}
className="text-red-500 hover:underline text-sm"
>
Удалить текущий баннер
</button>
</div>
)}
<input
type="file"
id="banner_picture"
name="banner_picture"
onChange={handleEditFileChange}
className="w-full text-campfire-light text-sm
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-campfire-amber file:text-campfire-dark
hover:file:bg-campfire-amber/80 cursor-pointer"
/>
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={handleEditFormCancel}
className="btn-secondary"
disabled={isSubmittingEdit}
>
Отмена
</button>
<button
type="submit"
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmittingEdit}
>
{isSubmittingEdit ? 'Сохранение...' : 'Сохранить изменения'}
</button>
</div>
</form>
</Modal>
)}
{/* Showcase Management Modal */}
{profileUser && isOwnProfile && (
<Modal
isOpen={isManagingShowcase}
onClose={handleCancelShowcase}
title={`Управление витриной (${selectedShowcaseReviewIds.length}/3)`}
size="lg"
>
<div className="space-y-4">
{editFormError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
Ошибка: {editFormError}
</div>
)}
<p className="text-campfire-ash text-sm">Выберите до 3 рецензий, которые будут отображаться на вашей витрине.</p>
{reviews.length > 0 ? (
<ul className="space-y-3 max-h-96 overflow-y-auto pr-2 custom-scrollbar">
{reviews.map(review => {
const reviewMedia = review.expand?.media_id;
const reviewSeason = review.expand?.season_id;
const isSelected = selectedShowcaseReviewIds.includes(review.id);
const reviewTitle = reviewMedia
? `${reviewMedia.title}${reviewSeason ? ` - Сезон ${reviewSeason.season_number}${reviewSeason.title ? `: ${reviewSeason.title}` : ''}` : ''}`
: 'Неизвестное произведение';
return (
<li
key={review.id}
className={`p-3 rounded-md border cursor-pointer flex items-center justify-between transition-colors duration-200
${isSelected
? 'bg-campfire-amber/20 border-campfire-amber text-campfire-light'
: 'bg-campfire-dark border-campfire-ash/30 text-campfire-ash hover:bg-campfire-ash/10'
}`}
onClick={() => handleToggleShowcaseReview(review.id)}
>
<div className="flex-grow mr-4">
<p className="font-semibold text-sm">{reviewTitle}</p>
<p className="text-xs mt-1 flex items-center">
Оценка: <span className="flex items-center text-campfire-amber ml-1 font-bold">
<FaFire className="mr-1 text-xs" />
{/* Corrected access to overall_rating */}
{review.overall_rating !== null && review.overall_rating !== undefined ? review.overall_rating.toFixed(1) : 'N/A'} / 10
</span>
</p>
</div>
{isSelected ? (
<FaCheckCircle size={20} className="text-campfire-amber" />
) : (
<FaRegCircle size={20} className="text-campfire-ash" />
)}
</li>
);
})}
</ul>
) : (
<div className="text-center py-4 text-campfire-ash">
У вас пока нет рецензий для добавления на витрину.
</div>
)}
{/* Form Actions */}
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={handleCancelShowcase}
className="btn-secondary"
disabled={isSubmittingEdit}
>
Отмена
</button>
<button
type="button"
onClick={handleSaveShowcase}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmittingEdit}
>
{isSubmittingEdit ? 'Сохранение...' : 'Сохранить витрину'}
</button>
</div>
</div>
</Modal>
)}
{/* Achievement Assignment Modal (New) */}
{profileUser && isAdmin && ( // Only show if profileUser is loaded and current user is admin
<Modal
isOpen={isAssigningAchievements}
onClose={handleCancelAchievementAssignment}
title={`Назначить достижения для ${profileUser.username}`}
size="lg"
>
<div className="space-y-4">
{editFormError && ( // Reuse error state
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
Ошибка: {editFormError}
</div>
)}
<p className="text-campfire-ash text-sm">Выберите достижения, которые нужно назначить или снять с пользователя.</p>
{availableAchievements.length > 0 ? (
<ul className="space-y-3 max-h-96 overflow-y-auto pr-2 custom-scrollbar">
{availableAchievements.map(achievement => {
const isSelected = selectedAchievementIdsToAssign.includes(achievement.id);
return (
<li
key={achievement.id}
className={`p-3 rounded-md border cursor-pointer flex items-center justify-between transition-colors duration-200
${isSelected
? 'bg-campfire-amber/20 border-campfire-amber text-campfire-light'
: 'bg-campfire-dark border-campfire-ash/30 text-campfire-ash hover:bg-campfire-ash/10'
}`}
onClick={() => handleToggleAchievementAssignment(achievement.id)}
>
<div className="flex-grow mr-4">
<p className="font-semibold text-sm">{achievement.name}</p>
<p className="text-xs text-campfire-ash mt-1">{achievement.description}</p>
{achievement.xp_reward > 0 && (
<p className="text-campfire-light text-xs mt-1 flex items-center">
<FaFire className="mr-1 text-campfire-amber" /> +{achievement.xp_reward} XP
</p>
)}
</div>
{isSelected ? (
<FaCheckCircle size={20} className="text-campfire-amber" />
) : (
<FaRegCircle size={20} className="text-campfire-ash" />
)}
</li>
);
})}
</ul>
) : (
<div className="text-center py-4 text-campfire-ash">
Нет доступных достижений.
</div>
)}
{/* Form Actions */}
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={handleCancelAchievementAssignment}
className="btn-secondary"
disabled={isSubmittingEdit}
>
Отмена
</button>
<button
type="button"
onClick={handleSaveAchievementAssignment}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmittingEdit}
>
{isSubmittingEdit ? 'Сохранение...' : 'Сохранить изменения'}
</button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default ProfilePage;

View File

@ -0,0 +1,433 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Navigate, useNavigate } from 'react-router-dom';
import { updateUserProfile, uploadFile, deleteFile, getFileUrl } from '../services/pocketbaseService'; // Use PocketBase service
import { toast } from 'react-toastify';
import { FaSpinner } from 'react-icons/fa';
const ProfileSettingsPage = () => {
const { user, userProfile, isInitialized, loading: authLoading, error: authError, refreshUserProfile } = useAuth(); // user and userProfile are the same PocketBase record
const navigate = useNavigate();
const [formData, setFormData] = useState({
username: '',
description: '',
profile_picture_file: undefined, // Use undefined initially to distinguish from null (cleared)
banner_picture_file: undefined, // Use undefined initially to distinguish from null (cleared)
});
const [previewAvatar, setPreviewAvatar] = useState(null); // URL for avatar preview
const [previewBanner, setPreviewBanner] = useState(null); // URL for banner preview
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
// Store initial data for comparison
const [initialData, setInitialData] = useState(null);
// Store initial file names for comparison (PocketBase stores filenames)
const [initialFiles, setInitialFiles] = useState({
profile_picture: null,
banner_picture: null, // Corrected key
});
// Effect to populate form when userProfile is loaded
useEffect(() => {
if (userProfile) {
const dataToPopulate = {
username: userProfile.username || '',
description: userProfile.description || '',
profile_picture_file: undefined, // Reset file inputs to undefined (not touched)
banner_picture_file: undefined, // Reset file inputs to undefined (not touched)
};
setFormData(dataToPopulate);
// Store initial data for comparison
setInitialData(dataToPopulate);
// Store initial file names
setInitialFiles({
profile_picture: userProfile.profile_picture || null,
banner_picture: userProfile.banner_picture || null, // Corrected key
});
// Use the actual URLs from userProfile for initial previews
// PocketBase file fields store filenames, use getFileUrl to get the actual URL
setPreviewAvatar(getFileUrl(userProfile, 'profile_picture') || null);
setPreviewBanner(getFileUrl(userProfile, 'banner_picture') || null); // Corrected field name
}
}, [userProfile]);
// Wait for auth to initialize before deciding
if (!isInitialized || authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark pt-20">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
// If user is not logged in, redirect to login page
if (!user) {
return <Navigate to="/auth" replace />;
}
// If user is logged in but profile is not loaded yet (and not saving), show loading
// This handles the case where user is logged in but userProfile is still null initially
// In PocketBase, user and userProfile are the same record from authStore.model,
// so if user exists, userProfile should also exist unless there's an init issue.
// Still good to keep this check for robustness.
if (!userProfile && !isSaving) {
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark pt-20">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
const handleFileChange = (e) => {
const { name, files } = e.target;
const file = files[0];
// Determine which file input changed based on the 'name' attribute
const isAvatar = name === 'profile_picture';
const isBanner = name === 'banner_picture'; // Corrected name check
if (file) {
// Update the correct file state variable
if (isAvatar) {
setFormData(prev => ({ ...prev, profile_picture_file: file }));
// Create a preview URL
const reader = new FileReader();
reader.onloadend = () => setPreviewAvatar(reader.result);
reader.readAsDataURL(file);
} else if (isBanner) { // Corrected check
setFormData(prev => ({ ...prev, banner_picture_file: file })); // Corrected state key
// Create a preview URL
const reader = new FileReader();
reader.onloadend = () => setPreviewBanner(reader.result);
reader.readAsDataURL(file);
}
} else {
// If file input is cleared (user cancels file selection or clicks clear)
if (isAvatar) {
setFormData(prev => ({ ...prev, profile_picture_file: null })); // Set to null to indicate clearing
// Revert preview to the original URL from userProfile if it exists
setPreviewAvatar(getFileUrl(userProfile, 'profile_picture') || null);
} else if (isBanner) { // Corrected check
setFormData(prev => ({ ...prev, banner_picture_file: null })); // Set to null to indicate clearing
// Revert preview to the original URL from userProfile if it exists
setPreviewBanner(getFileUrl(userProfile, 'banner_picture') || null); // Corrected field name
}
}
};
const handleClearFile = async (type) => {
if (!user || !userProfile) return;
const fileStateKey = type === 'avatar' ? 'profile_picture_file' : 'banner_picture_file'; // Corrected state key for banner
const previewSetter = type === 'avatar' ? setPreviewAvatar : setPreviewBanner;
// Clear the file input state by setting it to null
setFormData(prev => ({ ...prev, [fileStateKey]: null }));
// Clear the preview immediately
previewSetter(null);
console.log(`Marked ${type} file for clearing.`);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!user || !userProfile || !initialData || !initialFiles) return; // Ensure initialData and initialFiles are available
setIsSaving(true);
setSaveError(null);
const formDataToSubmit = new FormData();
let changesMade = false;
// Handle Text Field Changes
if (formData.username !== initialData.username) {
formDataToSubmit.append('username', formData.username);
changesMade = true;
console.log('Username changed:', formData.username);
}
if (formData.description !== initialData.description) {
formDataToSubmit.append('description', formData.description);
changesMade = true;
console.log('Description changed:', formData.description);
}
// Handle Avatar File Change/Clear
const newAvatarFile = formData.profile_picture_file; // Can be File, null (cleared), or undefined (not touched)
const initialAvatarFileName = initialFiles.profile_picture; // Can be string (filename) or null
// Check if the avatar file input was touched (either a new file selected or cleared)
if (newAvatarFile !== undefined) {
if (newAvatarFile instanceof File) {
// New avatar file selected
console.log('New avatar file selected. Adding to FormData.');
formDataToSubmit.append('profile_picture', newAvatarFile);
changesMade = true;
} else if (newAvatarFile === null && initialAvatarFileName) {
// Avatar input was cleared AND there was an existing avatar - mark for deletion
console.log('Avatar cleared. Marking profile_picture for deletion.');
formDataToSubmit.append('profile_picture', null); // Set field to null to delete file
changesMade = true;
}
// If newAvatarFile is null and initialAvatarFileName is null, do nothing (already no file).
}
// If newAvatarFile is undefined, the input wasn't touched, do nothing (keep existing file if any).
// Handle Banner File Change/Clear
const newBannerFile = formData.banner_picture_file; // Can be File, null (cleared), or undefined (not touched)
const initialBannerFileName = initialFiles.banner_picture; // Can be string (filename) or null
// Check if the banner file input was touched (either a new file selected or cleared)
if (newBannerFile !== undefined) {
if (newBannerFile instanceof File) {
// New banner file selected
console.log('New banner file selected. Adding to FormData.');
formDataToSubmit.append('banner_picture', newBannerFile);
changesMade = true;
} else if (newBannerFile === null && initialBannerFileName) {
// Banner input was cleared AND there was an existing banner - mark for deletion
console.log('Banner cleared. Marking banner_picture for deletion.');
formDataToSubmit.append('banner_picture', null); // Set field to null to delete file
changesMade = true;
}
// If newBannerFile is null and initialBannerFileName is null, do nothing (already no file).
}
// If newBannerFile is undefined, the input wasn't touched, do nothing (keep existing file if any).
// Check if any changes were made
if (!changesMade) {
console.log('No changes detected, cancelling save.');
toast.info('Нет изменений для сохранения.');
setIsSaving(false);
return; // Stop the submission process
}
// Log FormData contents before sending (for debugging)
console.log('Attempting to update profile with FormData:');
for (let pair of formDataToSubmit.entries()) {
console.log(pair[0]+ ': ' + pair[1]);
}
try {
// PocketBase update method correctly handles FormData with partial updates
// Only fields present in FormData will be updated.
await updateUserProfile(user.id, formDataToSubmit); // Pass the single FormData object
console.log('Profile update successful.');
// Refresh the user record in context to get the latest data and file URLs
await refreshUserProfile();
toast.success('Профиль успешно обновлен!');
navigate(`/profile/${user.username}`);
} catch (error) {
console.error('Failed to save profile:', error);
if (error.response && error.response.data) {
console.error('PocketBase: Response data:', error.response.data);
// Check for specific PocketBase validation errors
if (error.response.data.username && error.response.data.username.code === 'validation_not_unique') {
setSaveError('Имя пользователя уже занято.');
toast.error('Имя пользователя уже занято.');
} else {
// Generic error message from PocketBase response
const errorMessages = Object.values(error.response.data).map(err => err.message).join(', ');
setSaveError(`Ошибка сохранения: ${errorMessages || error.message}`);
toast.error(`Ошибка сохранения профиля: ${errorMessages || error.message}`);
}
} else {
// General error message
setSaveError(error.message || 'Произошла ошибка при сохранении.');
toast.error(`Ошибка сохранения профиля: ${error.message || 'Произошла ошибка'}`);
}
} finally {
setIsSaving(false);
}
};
// Render the settings page content for the logged-in user
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="bg-campfire-charcoal rounded-lg shadow-lg border border-campfire-ash/20 p-8">
<h1 className="text-2xl font-bold text-campfire-light mb-6">
Настройки профиля
</h1>
{saveError && (
<div className="bg-red-800 text-white p-3 rounded mb-4">
{saveError}
</div>
)}
{userProfile && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Avatar Upload */}
<div>
<label htmlFor="profile_picture" className="block text-sm font-medium text-campfire-ash mb-2">
Аватар
</label>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<img
className="h-20 w-20 rounded-full object-cover border border-campfire-ash/30"
// Use previewAvatar (FileReader URL) if a new file is selected,
// otherwise use the URL from the userProfile (generated by getFileUrl)
src={previewAvatar || getFileUrl(userProfile, 'profile_picture') || 'https://via.placeholder.com/150/333333/FFFFFF?text=No+Avatar'}
alt="Аватар пользователя"
/>
</div>
<div>
<input
type="file"
id="profile_picture"
name="profile_picture" // Use name matching handleFileChange logic
accept="image/*"
onChange={handleFileChange}
className="block w-full text-sm text-campfire-ash
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-campfire-amber file:text-campfire-dark
hover:file:bg-campfire-amber/80 cursor-pointer"
/>
{(previewAvatar || getFileUrl(userProfile, 'profile_picture')) && ( // Show clear button if there's a preview or existing picture
<button
type="button"
onClick={() => handleClearFile('avatar')}
className="mt-2 text-sm text-red-400 hover:text-red-500"
>
Удалить аватар
</button>
)}
</div>
</div>
</div>
{/* Banner Upload */}
<div>
<label htmlFor="banner_picture" className="block text-sm font-medium text-campfire-ash mb-2"> {/* Corrected htmlFor and label text */}
Баннер
</label>
<div className="relative w-full h-40 rounded-lg overflow-hidden border border-campfire-ash/30 bg-campfire-dark">
{/* Use previewBanner or getFileUrl for banner */}
{(previewBanner || getFileUrl(userProfile, 'banner_picture')) ? (
<img
className="w-full h-full object-cover"
src={previewBanner || getFileUrl(userProfile, 'banner_picture')} // Corrected field name
alt="Баннер пользователя"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-campfire-ash/50">
Нет баннера
</div>
)}
<input
type="file"
id="banner_picture" // Corrected id
name="banner_picture" // Corrected name matching handleFileChange logic
accept="image/*"
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
{(previewBanner || getFileUrl(userProfile, 'banner_picture')) && ( // Show clear button if there's a preview or existing banner (Corrected field name)
<button
type="button"
onClick={() => handleClearFile('banner')}
className="mt-2 text-sm text-red-400 hover:text-red-500"
>
Удалить баннер
</button>
)}
</div>
{/* Username Input */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-campfire-ash mb-2">
Имя пользователя
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber"
required
/>
</div>
{/* Description (Bio) Textarea */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-campfire-ash mb-2">
О себе (Биография)
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
rows="4"
className="w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber"
></textarea>
</div>
{/* Stats (Placeholder) */}
<div>
<h3 className="text-lg font-semibold text-campfire-light mb-4">Статистика</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-campfire-ash">
<div className="bg-campfire-dark p-4 rounded-md border border-campfire-ash/30">
<p className="text-sm">Всего обзоров:</p>
<p className="text-xl font-bold text-campfire-light">{userProfile.review_count || 0}</p>
</div>
<div className="bg-campfire-dark p-4 rounded-md border border-campfire-ash/30">
<p className="text-sm">Средняя оценка:</p>
<p className="text-xl font-bold text-campfire-light">{userProfile.average_rating ? userProfile.average_rating.toFixed(1) : 'N/A'}</p>
</div>
{/* Add more stats here if available in userProfile */}
</div>
</div>
{/* Save Button */}
<div>
<button
type="submit"
className="w-full bg-campfire-amber text-campfire-dark font-bold py-2 px-4 rounded-md hover:bg-campfire-amber/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-campfire-amber focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
>
{isSaving ? (
<span className="flex items-center">
<FaSpinner className="animate-spin mr-2" />
Сохранение...
</span>
) : (
'Сохранить изменения'
)}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};
export default ProfileSettingsPage;

210
src/pages/RatingPage.jsx Normal file
View File

@ -0,0 +1,210 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { listMedia, listUsersRankedByReviews, listUsersRankedByLevel, mediaTypes, getFileUrl, getReviewsByLikes } from '../services/pocketbaseService';
import { useAuth } from '../contexts/AuthContext';
import { useProfileActions } from '../contexts/ProfileActionsContext';
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
import { FaFire, FaStar, FaUsers, FaLevelUpAlt, FaMedal, FaHeart } from 'react-icons/fa';
const RatingPage = () => {
const { userProfile } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState('media');
const [mediaByRating, setMediaByRating] = useState([]);
const [mediaByReviews, setMediaByReviews] = useState([]);
const [usersByReviews, setUsersByReviews] = useState([]);
const [usersByLevel, setUsersByLevel] = useState([]);
const [reviewsByLikes, setReviewsByLikes] = useState([]);
useEffect(() => {
window.scrollTo(0, 0);
}, []);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [
mediaRatingData,
mediaReviewsData,
usersReviewsData,
usersLevelData,
reviewsLikesData
] = await Promise.all([
listMedia(null, 1, 20, userProfile, false, true, '-average_rating'),
listMedia(null, 1, 20, userProfile, false, true, '-review_count'),
listUsersRankedByReviews(20),
listUsersRankedByLevel(20),
getReviewsByLikes(20)
]);
setMediaByRating(mediaRatingData.data || []);
setMediaByReviews(mediaReviewsData.data || []);
setUsersByReviews(usersReviewsData || []);
setUsersByLevel(usersLevelData || []);
setReviewsByLikes(reviewsLikesData || []);
} catch (err) {
console.error('Error fetching rating data:', err);
setError('Не удалось загрузить данные рейтинга.');
} finally {
setLoading(false);
}
}, [userProfile]);
useEffect(() => {
fetchData();
}, [fetchData]);
const renderMediaList = (mediaItems) => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-8 md:gap-20">
{mediaItems.map((mediaItem) => (
<Link to={`/media/${mediaItem.path}`} key={mediaItem.id} className="block">
<TiltedCard
imageSrc={getFileUrl(mediaItem, 'poster')}
altText={mediaItem.title}
captionText={mediaItem.title}
containerHeight="360px"
containerWidth="100%"
imageHeight="360px"
imageWidth="240px"
scaleOnHover={1.05}
rotateAmplitude={10}
showMobileWarning={false}
showTooltip={false}
displayOverlayContent={false}
rating={mediaItem.average_rating}
releaseDate={mediaItem.release_date}
/>
</Link>
))}
</div>
);
const renderUserList = (userItems, rankType) => (
<div className="space-y-4">
{userItems.map((user, index) => (
<Link to={`/profile/${user.username}`} key={user.id} className="bg-campfire-charcoal rounded-lg shadow-md p-4 flex items-center border border-campfire-ash/20 hover:border-campfire-amber transition-colors duration-200">
<span className="text-campfire-amber font-bold text-xl mr-4 w-8 text-center">{index + 1}.</span>
<img
src={getFileUrl(user, 'profile_picture') || 'https://images.pexels.com/photos/1704488/pexels-photo-1704488.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'}
alt={user.username}
className="w-12 h-12 rounded-full object-cover mr-4 border border-campfire-ash/30"
/>
<div className="flex-grow">
<h3 className="text-lg font-semibold text-campfire-light">{user.username}</h3>
{rankType === 'reviews' && (
<p className="text-campfire-ash text-sm">Рецензий: <b className="text-campfire-amber">{user.review_count || 0}</b></p>
)}
{rankType === 'level' && (
<p className="text-campfire-ash text-sm">Уровень: <b className="text-campfire-amber">{user.level || 1}</b></p>
)}
</div>
</Link>
))}
</div>
);
const renderReviewList = (reviews) => (
<div className="space-y-4">
{reviews.map((review, index) => (
<Link to={`/media/${review.expand.media_id.path}`} key={review.id} className="block">
<div className="bg-campfire-charcoal rounded-lg shadow-md p-4 border border-campfire-ash/20 hover:border-campfire-amber transition-colors duration-200">
<div className="flex items-center mb-2">
<span className="text-campfire-amber font-bold text-xl mr-4 w-8 text-center">{index + 1}.</span>
<div className="flex-grow">
<h3 className="text-lg font-semibold text-campfire-light">{review.expand.media_id.title}</h3>
<p className="text-campfire-ash text-sm">Автор: {review.expand.user_id.username}</p>
</div>
<div className="flex items-center text-campfire-amber">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
</div>
<div className="text-campfire-ash line-clamp-2" dangerouslySetInnerHTML={{ __html: review.content }} />
</div>
</Link>
))}
</div>
);
if (loading) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-campfire-dark pt-20 container-custom py-8">
<h1 className="text-3xl font-bold mb-6 text-campfire-light">Рейтинги</h1>
<div className="flex border-b border-campfire-ash/20 mb-8 overflow-x-auto">
<button
className={`px-4 py-2 text-sm font-medium ${activeTab === 'media-rating' ? 'text-campfire-amber border-b-2 border-campfire-amber' : 'text-campfire-ash hover:text-campfire-light'}`}
onClick={() => setActiveTab('media-rating')}
>
Медиа по рейтингу
</button>
<button
className={`px-4 py-2 text-sm font-medium ${activeTab === 'media-reviews' ? 'text-campfire-amber border-b-2 border-campfire-amber' : 'text-campfire-ash hover:text-campfire-light'}`}
onClick={() => setActiveTab('media-reviews')}
>
Медиа по рецензиям
</button>
<button
className={`px-4 py-2 text-sm font-medium ${activeTab === 'reviews-likes' ? 'text-campfire-amber border-b-2 border-campfire-amber' : 'text-campfire-ash hover:text-campfire-light'}`}
onClick={() => setActiveTab('reviews-likes')}
>
Рецензии по лайкам
</button>
<button
className={`px-4 py-2 text-sm font-medium ${activeTab === 'users-reviews' ? 'text-campfire-amber border-b-2 border-campfire-amber' : 'text-campfire-ash hover:text-campfire-light'}`}
onClick={() => setActiveTab('users-reviews')}
>
Пользователи по рецензиям
</button>
<button
className={`px-4 py-2 text-sm font-medium ${activeTab === 'users-level' ? 'text-campfire-amber border-b-2 border-campfire-amber' : 'text-campfire-ash hover:text-campfire-light'}`}
onClick={() => setActiveTab('users-level')}
>
Пользователи по уровню
</button>
</div>
<div>
{activeTab === 'media-rating' && (
mediaByRating.length > 0 ? renderMediaList(mediaByRating) : <p className="text-campfire-ash text-center py-8">Нет данных для этого рейтинга.</p>
)}
{activeTab === 'media-reviews' && (
mediaByReviews.length > 0 ? renderMediaList(mediaByReviews) : <p className="text-campfire-ash text-center py-8">Нет данных для этого рейтинга.</p>
)}
{activeTab === 'reviews-likes' && (
reviewsByLikes.length > 0 ? renderReviewList(reviewsByLikes) : <p className="text-campfire-ash text-center py-8">Нет данных для этого рейтинга.</p>
)}
{activeTab === 'users-reviews' && (
usersByReviews.length > 0 ? renderUserList(usersByReviews, 'reviews') : <p className="text-campfire-ash text-center py-8">Нет данных для этого рейтинга.</p>
)}
{activeTab === 'users-level' && (
usersByLevel.length > 0 ? renderUserList(usersByLevel, 'level') : <p className="text-campfire-ash text-center py-8">Нет данных для этого рейтинга.</p>
)}
</div>
</div>
);
};
export default RatingPage;

249
src/pages/RegisterPage.jsx Normal file
View File

@ -0,0 +1,249 @@
import { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
function RegisterPage() {
const [login, setLogin] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { user, userProfile, loading: authLoading, signUp, isInitialized } = useAuth();
const navigate = useNavigate();
console.log('RegisterPage: Rendering. State:', { user: !!user, userProfile: !!userProfile, authLoading, isInitialized, componentLoading: loading, componentError: error }); // Add component render log
useEffect(() => {
console.log('RegisterPage useEffect: Running. State:', { user: !!user, userProfile: !!userProfile, authLoading, isInitialized }); // Add useEffect log
if (!authLoading && isInitialized) { // Wait for auth to be initialized and not loading
if (user && userProfile) {
console.log('RegisterPage useEffect: User and profile loaded, redirecting to home.');
navigate("/"); // Redirect after successful registration or if already logged in
} else {
console.log('RegisterPage useEffect: No user and auth initialized, staying on register.');
}
} else if (authLoading) {
console.log('RegisterPage useEffect: Auth is loading...');
} else if (!isInitialized) {
console.log('RegisterPage useEffect: Auth is not initialized...');
}
}, [user, userProfile, authLoading, isInitialized, navigate]); // Add isInitialized to dependencies
const handleSubmit = async (e) => {
e.preventDefault();
if (!login || !password || !confirmPassword) {
setError("Заполни обязательные поля");
return;
}
if (password !== confirmPassword) {
setError("Хмм, пароли не совпадают...");
return;
}
if (password.length < 6) {
setError("Коротковато...");
return;
}
// Проверка на допустимые символы в логине
if (!/^[a-zA-Z0-9_]+$/.test(login)) {
setError("Логин может содержать только латинские буквы, цифры и знак подчеркивания");
return;
}
// Проверка длины логина
if (login.length < 3 || login.length > 20) {
setError("Логин должен быть от 3 до 20 символов");
return;
}
try {
setError("");
setLoading(true);
console.log('RegisterPage handleSubmit: Attempting sign up...');
await signUp(login, password, email || null);
console.log('RegisterPage handleSubmit: Sign up process initiated. Redirect handled by useEffect.');
// Redirect is now handled by the useEffect based on auth state change
} catch (err) {
console.error("Registration error:", err);
// More specific error handling for PocketBase unique constraints
if (err.response && err.response.data) {
const errorData = err.response.data;
if (errorData.login && errorData.login.code === 'validation_not_unique') {
setError("Этот логин уже занят");
} else if (errorData.email && errorData.email.code === 'validation_not_unique') {
setError("Пользователь с таким email уже существует");
} else {
setError(err.message || "Произошла ошибка при регистрации");
}
} else {
setError(err.message || "Произошла ошибка при регистрации");
}
} finally {
setLoading(false);
}
};
// Render loading state based on AuthContext loading
// Removed the check here to allow the form to render even if auth is loading,
// relying on the AuthProvider's own loading state handling.
// This might help diagnose if the component itself is not rendering.
// if (authLoading || !isInitialized) {
// console.log('RegisterPage: Rendering initial auth loading state.');
// return (
// <div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark text-campfire-light">
// Загрузка авторизации...
// </div>
// );
// }
console.log('RegisterPage: Rendering registration form.'); // Log before rendering form
return (
<div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark text-campfire-light">
<div className="container-custom max-w-md py-12">
<div className="bg-campfire-charcoal rounded-lg shadow-lg p-8 border border-campfire-ash/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-campfire-light mb-2">
Присоединиться к CampFire мнеие
</h1>
<p className="text-campfire-ash">
Создай свою учетную запись CampFire, чтобы оценивать и рецензировать
все что движется.
</p>
</div>
{error && (
<div className="bg-status-error/20 text-status-error p-4 rounded-md mb-6 border border-status-error/30">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="login" className="block text-sm font-medium text-campfire-light mb-1">
Логин *
</label>
<input
type="text"
id="login"
value={login}
onChange={(e) => setLogin(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="Введите логин"
required
autoComplete="username"
/>
<p className="text-xs text-campfire-ash mt-1">
От 3 до 20 символов, только латинские буквы, цифры и _
</p>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-campfire-light mb-1">
Email (необязательно)
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="your@campfiregg.ru"
autoComplete="email"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-campfire-light mb-1">
Пароль *
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="••••••••"
required
autoComplete="new-password"
/>
<p className="text-xs text-campfire-ash mt-1">
Не меньше 6 знаков
</p>
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-campfire-light mb-1">
Повторите пароль *
</label>
<input
type="password"
id="confirm-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="••••••••"
required
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={loading}
className={`btn-primary w-full ${loading ? "opacity-80 cursor-not-allowed" : ""} transition-colors duration-200`}
>
{loading ? (
<span className="inline-flex items-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Запечатываем...
</span>
) : (
"Создать"
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-campfire-ash">
Уже в строю?{" "}
<Link
to="/auth/login"
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors"
>
Войти
</Link>
</div>
</div>
</div>
</div>
);
}
export default RegisterPage;

410
src/pages/SupportPage.jsx Normal file
View File

@ -0,0 +1,410 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { pb } from '../services/pocketbaseService';
import { toast } from 'react-hot-toast';
import { FaSpinner, FaCheck, FaTimes, FaClock, FaPlus } from 'react-icons/fa';
import SupportTicketForm from '../components/support/SupportTicketForm';
import SuggestCardWidget from '../components/suggestions/SuggestCardWidget';
import { useNavigate } from 'react-router-dom';
const SupportPage = () => {
const { user } = useAuth();
const [tickets, setTickets] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true);
const [showTicketForm, setShowTicketForm] = useState(false);
const [showSuggestionForm, setShowSuggestionForm] = useState(false);
const [activeTab, setActiveTab] = useState('tickets'); // 'tickets' или 'suggestions'
const [replyText, setReplyText] = useState('');
const [replyingTo, setReplyingTo] = useState(null);
const navigate = useNavigate();
useEffect(() => {
if (user) {
loadUserData();
}
}, [user]);
const loadUserData = async () => {
try {
setLoading(true);
// Загружаем тикеты пользователя
const ticketsData = await pb.collection('support_tickets').getList(1, 50, {
filter: `user_id = "${user.id}"`,
sort: '-created'
});
// Загружаем предложения пользователя
const suggestionsData = await pb.collection('suggestions').getList(1, 50, {
filter: `user = "${user.id}"`,
sort: '-created'
});
setTickets(ticketsData.items);
setSuggestions(suggestionsData.items);
} catch (error) {
console.error('Ошибка при загрузке данных:', error);
toast.error('Не удалось загрузить данные');
} finally {
setLoading(false);
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'open':
return <FaClock className="text-status-warning" />;
case 'in_progress':
return <FaSpinner className="text-status-info animate-spin" />;
case 'closed':
return <FaCheck className="text-status-success" />;
default:
return null;
}
};
const getStatusLabel = (status) => {
switch (status) {
case 'open':
return 'Открыт';
case 'in_progress':
return 'В работе';
case 'closed':
return 'Закрыт';
default:
return status;
}
};
const getSuggestionStatusLabel = (status) => {
switch (status) {
case 'pending':
return 'На рассмотрении';
case 'approved':
return 'Одобрено';
case 'rejected':
return 'Отклонено';
default:
return status;
}
};
const getSuggestionStatusColor = (status) => {
switch (status) {
case 'pending':
return 'text-status-warning';
case 'approved':
return 'text-status-success';
case 'rejected':
return 'text-status-error';
default:
return 'text-campfire-ash';
}
};
const handleTicketCreated = () => {
setShowTicketForm(false);
loadUserData();
toast.success('Обращение успешно создано');
};
const formatMessages = (message) => {
// Разбиваем сообщение на части по датам
const parts = message.split(/\n\n/);
return parts.map(part => {
const [date, ...content] = part.split('\n');
return {
date: date.trim(),
content: content.join('\n').trim()
};
}).sort((a, b) => new Date(a.date) - new Date(b.date));
};
const handleReply = async (ticketId) => {
if (!replyText.trim()) return;
try {
setLoading(true);
const ticket = await pb.collection('support_tickets').getOne(ticketId);
const timestamp = new Date().toLocaleString();
const updatedMessage = ticket.message
? `${ticket.message}\n\n${timestamp}\n${replyText}`
: `${timestamp}\n${replyText}`;
await pb.collection('support_tickets').update(ticketId, {
message: updatedMessage,
status: 'open'
});
// Обновляем локальное состояние
setTickets(prevTickets =>
prevTickets.map(ticket =>
ticket.id === ticketId
? { ...ticket, message: updatedMessage, status: 'open' }
: ticket
)
);
setReplyText('');
setReplyingTo(null);
toast.success('Сообщение отправлено');
} catch (error) {
console.error('Error sending reply:', error);
toast.error('Не удалось отправить сообщение');
} finally {
setLoading(false);
}
};
if (!user) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="text-campfire-light text-center">
<p className="mb-4">Для доступа к поддержке необходимо войти в систему</p>
<button
onClick={() => window.location.href = '/login'}
className="btn-primary"
>
Войти
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-campfire-light">Поддержка</h1>
<div className="flex space-x-4">
<button
onClick={() => setShowTicketForm(true)}
className="btn-primary flex items-center space-x-2"
>
<FaPlus />
<span>Создать обращение</span>
</button>
<button
onClick={() => setShowSuggestionForm(true)}
className="btn-secondary flex items-center space-x-2"
>
<FaPlus />
<span>Предложить карточку</span>
</button>
</div>
</div>
{/* Tabs */}
<div className="flex space-x-4 mb-8 border-b border-campfire-ash/20">
<button
className={`pb-4 px-4 font-medium ${
activeTab === 'tickets'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-light/70 hover:text-campfire-light'
}`}
onClick={() => setActiveTab('tickets')}
>
Мои обращения
</button>
<button
className={`pb-4 px-4 font-medium ${
activeTab === 'suggestions'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-light/70 hover:text-campfire-light'
}`}
onClick={() => setActiveTab('suggestions')}
>
Мои предложения
</button>
</div>
{loading ? (
<div className="flex justify-center items-center py-12">
<FaSpinner className="animate-spin text-4xl text-campfire-amber" />
</div>
) : (
<>
{activeTab === 'tickets' ? (
<div className="space-y-4">
{tickets.length === 0 ? (
<div className="text-center py-8 text-campfire-light/70">
У вас пока нет обращений в поддержку
</div>
) : (
tickets.map(ticket => (
<div
key={ticket.id}
className="bg-campfire-charcoal rounded-lg p-6 border border-campfire-ash/20"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-campfire-light mb-2">
{ticket.subject}
</h3>
<p className="text-campfire-ash">
{new Date(ticket.created).toLocaleString()}
</p>
</div>
<div className="flex items-center space-x-2">
{getStatusIcon(ticket.status)}
<span className="text-campfire-ash">
{getStatusLabel(ticket.status)}
</span>
</div>
</div>
<div className="mb-6">
<h4 className="text-campfire-light font-semibold mb-2">Сообщения:</h4>
<div className="space-y-4">
{formatMessages(ticket.message).map((msg, index) => (
<div key={index} className="bg-campfire-dark/50 rounded-lg p-4">
<div className="text-campfire-ash text-sm mb-2">{msg.date}</div>
<p className="text-campfire-light whitespace-pre-wrap">{msg.content}</p>
</div>
))}
</div>
</div>
{ticket.admin_notes && (
<div className="mb-6">
<h4 className="text-campfire-light font-semibold mb-2">Ответ поддержки:</h4>
<p className="text-campfire-ash whitespace-pre-wrap">{ticket.admin_notes}</p>
</div>
)}
{ticket.status !== 'closed' && (
<div className="mt-4">
{replyingTo === ticket.id ? (
<div className="space-y-4">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
rows="4"
placeholder="Введите ваше сообщение..."
/>
<div className="flex justify-end space-x-4">
<button
onClick={() => {
setReplyingTo(null);
setReplyText('');
}}
className="btn-secondary"
>
Отмена
</button>
<button
onClick={() => handleReply(ticket.id)}
className="btn-primary"
>
Отправить
</button>
</div>
</div>
) : (
<button
onClick={() => setReplyingTo(ticket.id)}
className="btn-secondary"
>
Ответить
</button>
)}
</div>
)}
</div>
))
)}
</div>
) : (
<div className="space-y-4">
{suggestions.length === 0 ? (
<div className="text-center py-8 text-campfire-light/70">
У вас пока нет предложений
</div>
) : (
suggestions.map(suggestion => (
<div
key={suggestion.id}
className="bg-campfire-charcoal rounded-lg p-6 border border-campfire-ash/20"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-campfire-light mb-2">
{suggestion.title}
</h3>
<p className="text-campfire-ash">
{new Date(suggestion.created).toLocaleString()}
</p>
</div>
<div className="flex items-center space-x-2">
<span className={`${getSuggestionStatusColor(suggestion.status)}`}>
{getSuggestionStatusLabel(suggestion.status)}
</span>
</div>
</div>
<div className="mb-6">
<h4 className="text-campfire-light font-semibold mb-2">Описание:</h4>
<p className="text-campfire-ash whitespace-pre-wrap">{suggestion.description}</p>
</div>
{suggestion.admin_notes && (
<div className="mb-6">
<h4 className="text-campfire-light font-semibold mb-2">Комментарий администратора:</h4>
<p className="text-campfire-ash whitespace-pre-wrap">{suggestion.admin_notes}</p>
</div>
)}
</div>
))
)}
</div>
)}
</>
)}
</div>
{/* Модальное окно создания тикета */}
{showTicketForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-campfire-charcoal rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-campfire-light">Создать обращение</h2>
<button
onClick={() => setShowTicketForm(false)}
className="text-campfire-ash hover:text-campfire-light"
>
<FaTimes />
</button>
</div>
<SupportTicketForm onSuccess={handleTicketCreated} />
</div>
</div>
)}
{/* Модальное окно создания предложения */}
{showSuggestionForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-campfire-charcoal rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-campfire-light">Предложить карточку</h2>
<button
onClick={() => setShowSuggestionForm(false)}
className="text-campfire-ash hover:text-campfire-light"
>
<FaTimes />
</button>
</div>
<SuggestCardWidget onSuccess={() => {
setShowSuggestionForm(false);
loadUserData();
}} />
</div>
</div>
)}
</div>
);
};
export default SupportPage;

View File

@ -0,0 +1,143 @@
import React, { useState, useEffect } from 'react';
import { getAllAchievements, pb } from '../../services/pocketbaseService'; // Import pb for file URL
import AchievementForm from '../../components/admin/AchievementForm'; // Import the form component
import Modal from '../../components/common/Modal'; // Assuming you have a Modal component
import { FaTrophy, FaEdit, FaTrash } from 'react-icons/fa'; // Icons
const AdminAchievementsPage = () => {
const [achievements, setAchievements] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showFormModal, setShowFormModal] = useState(false); // State for form modal
const [achievementToEdit, setAchievementToEdit] = useState(null); // State for editing
useEffect(() => {
loadAchievements();
}, []);
const loadAchievements = async () => {
try {
setLoading(true);
setError(null);
const data = await getAllAchievements();
setAchievements(data || []);
} catch (err) {
console.error('Error loading achievements:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
const handleCreateClick = () => {
setAchievementToEdit(null); // Ensure we are creating, not editing
setShowFormModal(true);
};
const handleEditClick = (achievement) => {
setAchievementToEdit(achievement);
setShowFormModal(true);
};
const handleDeleteClick = async (id) => {
if (!window.confirm('Вы уверены, что хотите удалить это достижение?')) {
return;
}
try {
// PocketBase delete requires the record ID
await pb.collection('achievements').delete(id);
console.log('Achievement deleted successfully:', id);
loadAchievements(); // Reload the list
} catch (err) {
console.error('Error deleting achievement:', err);
setError(err.message);
}
};
const handleFormClose = () => {
setShowFormModal(false);
setAchievementToEdit(null);
};
const handleFormSuccess = () => {
handleFormClose(); // Close the modal
loadAchievements(); // Reload the list
};
return (
<div> {/* Removed container-custom and pt-20 as it's now in AdminLayout */}
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-bold text-campfire-light">Управление достижениями</h2> {/* Changed to h2 */}
<button
onClick={handleCreateClick}
className="btn-primary"
>
Создать достижение
</button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg mb-6">
{error}
</div>
)}
{loading ? (
<div className="text-center py-8 text-campfire-light">Загрузка достижений...</div>
) : achievements.length === 0 ? (
<div className="text-center py-8 text-campfire-light">
Достижения не найдены
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{achievements.map((achievement) => (
<div
key={achievement.id}
className="bg-campfire-charcoal rounded-lg p-4 flex items-center space-x-4 border border-campfire-ash/20"
>
{/* Achievement Icon (using a placeholder for now) */}
<div className="flex-shrink-0 text-campfire-amber">
{/* You would ideally use the achievement.icon here if available */}
<FaTrophy size={30} />
</div>
<div className="flex-grow">
<h3 className="font-semibold text-campfire-light">{achievement.title}</h3>
<p className="text-sm text-campfire-ash">{achievement.description}</p>
<p className="text-xs text-campfire-amber mt-1">+ {achievement.xp_reward} XP</p>
</div>
<div className="flex flex-col space-y-2">
<button
onClick={() => handleEditClick(achievement)}
className="text-campfire-amber hover:text-campfire-light"
>
<FaEdit size={18} />
</button>
<button
onClick={() => handleDeleteClick(achievement.id)}
className="text-red-500 hover:text-red-400"
>
<FaTrash size={18} />
</button>
</div>
</div>
))}
</div>
)}
{/* Achievement Form Modal */}
<Modal
isOpen={showFormModal}
onClose={handleFormClose}
title={achievementToEdit ? 'Редактировать достижение' : 'Создать достижение'}
>
<AchievementForm
onSuccess={handleFormSuccess}
onCancel={handleFormClose}
achievementToEdit={achievementToEdit}
/>
</Modal>
</div>
);
};
export default AdminAchievementsPage;

View File

@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react';
import { getAdminStats } from '../../services/pocketbaseService';
import { FaUsers, FaFilm, FaComment, FaHeadset, FaLightbulb } from 'react-icons/fa';
const AdminDashboard = () => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const loadStats = async () => {
try {
const data = await getAdminStats();
setStats(data);
} catch (err) {
setError('Не удалось загрузить статистику');
console.error('Error loading stats:', err);
} finally {
setLoading(false);
}
};
loadStats();
}, []);
if (loading) {
return (
<div className="container-custom pt-8 text-campfire-light">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="container-custom pt-8 text-campfire-light">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
return (
<div className="container-custom pt-8 text-campfire-light">
<h1 className="text-3xl font-bold mb-6">Административная панель</h1>
<p className="text-campfire-ash mb-8">Добро пожаловать в административную панель CampFire Critics.</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-campfire-charcoal p-6 rounded-lg shadow-md border border-campfire-ash/20">
<div className="flex items-center space-x-4 mb-4">
<div className="bg-campfire-amber/20 p-3 rounded-lg">
<FaUsers className="text-campfire-amber text-2xl" />
</div>
<div>
<h3 className="text-xl font-semibold text-campfire-light">Пользователи</h3>
<p className="text-3xl font-bold text-campfire-amber">{stats?.users || 0}</p>
</div>
</div>
</div>
<div className="bg-campfire-charcoal p-6 rounded-lg shadow-md border border-campfire-ash/20">
<div className="flex items-center space-x-4 mb-4">
<div className="bg-campfire-amber/20 p-3 rounded-lg">
<FaFilm className="text-campfire-amber text-2xl" />
</div>
<div>
<h3 className="text-xl font-semibold text-campfire-light">Медиа</h3>
<p className="text-3xl font-bold text-campfire-amber">{stats?.media || 0}</p>
</div>
</div>
</div>
<div className="bg-campfire-charcoal p-6 rounded-lg shadow-md border border-campfire-ash/20">
<div className="flex items-center space-x-4 mb-4">
<div className="bg-campfire-amber/20 p-3 rounded-lg">
<FaComment className="text-campfire-amber text-2xl" />
</div>
<div>
<h3 className="text-xl font-semibold text-campfire-light">Обзоры</h3>
<p className="text-3xl font-bold text-campfire-amber">{stats?.reviews || 0}</p>
</div>
</div>
</div>
<div className="bg-campfire-charcoal p-6 rounded-lg shadow-md border border-campfire-ash/20">
<div className="flex items-center space-x-4 mb-4">
<div className="bg-campfire-amber/20 p-3 rounded-lg">
<FaHeadset className="text-campfire-amber text-2xl" />
</div>
<div>
<h3 className="text-xl font-semibold text-campfire-light">Тикеты</h3>
<p className="text-3xl font-bold text-campfire-amber">{stats?.tickets || 0}</p>
</div>
</div>
</div>
<div className="bg-campfire-charcoal p-6 rounded-lg shadow-md border border-campfire-ash/20">
<div className="flex items-center space-x-4 mb-4">
<div className="bg-campfire-amber/20 p-3 rounded-lg">
<FaLightbulb className="text-campfire-amber text-2xl" />
</div>
<div>
<h3 className="text-xl font-semibold text-campfire-light">Предложения</h3>
<p className="text-3xl font-bold text-campfire-amber">{stats?.suggestions || 0}</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default AdminDashboard;

View File

@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // Adjusted path
import { createMedia } from '../../services/pocketbaseService'; // Adjusted path
import MediaForm from '../../components/admin/MediaForm'; // Adjusted path
const AdminMediaCreatePage = () => {
const navigate = useNavigate();
const { user, userProfile, loading: authLoading } = useAuth();
const [error, setError] = useState(null);
useEffect(() => {
console.log('AdminMediaCreatePage mounted, user:', user);
if (authLoading) {
console.log('AdminMediaCreatePage: Auth loading...');
return;
}
if (!user) {
console.log('AdminMediaCreatePage: No user, redirecting to login');
navigate('/login');
return;
}
if (userProfile?.role !== 'admin') {
console.log('AdminMediaCreatePage: Access denied');
navigate('/');
return;
}
console.log('AdminMediaCreatePage: User is admin, ready to create media...');
}, [user, userProfile, authLoading, navigate]); // Depend on user, userProfile, authLoading
const handleCreate = async (formData) => {
try {
// Use the imported createMedia function from pocketbaseService
const { data, error: createError } = await createMedia(formData);
if (createError) throw createError;
console.log('Media created successfully!', data);
navigate('/admin/media'); // Redirect back to the media list
} catch (err) {
console.error('Error creating media:', err);
setError(err.message);
}
};
if (authLoading) {
return <div className="flex justify-center items-center h-screen text-campfire-light">Загрузка аутентификации...</div>;
}
if (!user || userProfile?.role !== 'admin') {
return null; // Redirect handled by useEffect
}
return (
<div className="container-custom pt-20">
<h1 className="text-3xl font-bold text-campfire-amber mb-8">Добавить медиа</h1>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg mb-6">
Ошибка: {error}
</div>
)}
<MediaForm
onSuccess={() => navigate('/admin/media')} // Redirect on success
onCancel={() => navigate('/admin/media')} // Redirect on cancel
/>
</div>
);
};
export default AdminMediaCreatePage;

View File

@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // Adjusted path
import { getMediaById, updateMedia } from '../../services/pocketbaseService'; // Adjusted path
import MediaForm from '../../components/admin/MediaForm'; // Adjusted path
const AdminMediaEditPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const { user, userProfile, loading: authLoading } = useAuth();
const [media, setMedia] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
console.log('AdminMediaEditPage mounted, id:', id, 'user:', user);
if (authLoading) {
console.log('AdminMediaEditPage: Auth loading...');
return;
}
if (!user) {
console.log('AdminMediaEditPage: No user, redirecting to login');
navigate('/login');
return;
}
if (userProfile?.role !== 'admin') {
console.log('AdminMediaEditPage: Access denied');
navigate('/');
return;
}
console.log('AdminMediaEditPage: User is admin, loading media for edit...');
const loadMedia = async () => {
try {
setLoading(true);
const { data, error: fetchError } = await getMediaById(id);
if (fetchError) throw fetchError;
setMedia(data);
} catch (err) {
console.error('Error loading media for edit:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
if (id) {
loadMedia();
} else {
setError('Media ID is missing.');
setLoading(false);
}
}, [id, user, userProfile, authLoading, navigate]); // Depend on id, user, userProfile, authLoading
const handleUpdate = async (formData) => {
try {
// Use the imported updateMedia function from pocketbaseService
const success = await updateMedia(id, formData);
if (success) {
console.log('Media updated successfully!');
navigate('/admin/media'); // Redirect back to the media list
} else {
throw new Error('Failed to update media'); // Handle potential non-success from service
}
} catch (err) {
console.error('Error updating media:', err);
setError(err.message);
}
};
if (authLoading) {
return <div className="flex justify-center items-center h-screen text-campfire-light">Загрузка аутентификации...</div>;
}
if (!user || userProfile?.role !== 'admin') {
return null; // Redirect handled by useEffect
}
if (loading) {
return <div className="text-center py-8 text-campfire-light">Загрузка медиа для редактирования...</div>;
}
if (error) {
return (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg mb-6">
Ошибка: {error}
</div>
);
}
if (!media) {
return <div className="text-center py-8 text-campfire-light">Медиа-контент не найден.</div>;
}
return (
<div className="container-custom pt-20">
<h1 className="text-3xl font-bold text-campfire-amber mb-8">Редактировать медиа</h1>
<MediaForm
mediaToEdit={media}
onSuccess={() => navigate('/admin/media')} // Redirect on success
onCancel={() => navigate('/admin/media')} // Redirect on cancel
/>
</div>
);
};
export default AdminMediaEditPage;

View File

@ -0,0 +1,309 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { listMedia, deleteMedia, mediaTypes } from '../../services/pocketbaseService';
import Modal from '../../components/common/Modal';
import MediaForm from '../../components/admin/MediaForm';
import { FaEdit, FaTrashAlt, FaPlus, FaTv } from 'react-icons/fa'; // Import FaTv icon
const AdminMediaPage = () => {
const navigate = useNavigate();
const { user, userProfile, loading: authLoading } = useAuth();
const [mediaList, setMediaList] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [mediaToEdit, setMediaToEdit] = useState(null);
const [filterType, setFilterType] = useState(''); // State for type filter
const [filterPublished, setFilterPublished] = useState(''); // State for published filter ('', 'true', 'false')
// Check if the current user is admin
const isAdmin = userProfile?.role === 'admin';
// Use useCallback to memoize loadMediaData
const loadMediaData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Pass filterType and filterPublished to listMedia
const data = await listMedia(filterType || null, 1, 50, user, false, filterPublished === '' ? null : filterPublished === 'true'); // Fetch up to 50 items for admin view, not popular, filter published based on state
setMediaList(data.data || []);
} catch (err) {
console.error('Error loading media data:', err);
setError('Не удалось загрузить данные контента.');
} finally {
setLoading(false);
}
}, [filterType, filterPublished, user]); // Depend on filter states and user
useEffect(() => {
console.log('AdminMediaPage mounted, user:', user);
// Wait for auth to finish loading before checking user/profile
if (authLoading) {
console.log('AdminMediaPage: Auth loading...');
return;
}
// Redirect if not admin (AdminRoute should handle this, but double-check)
if (!user || !isAdmin) {
console.warn('AdminMediaPage: User is not admin, redirecting.');
navigate('/admin'); // Redirect to admin dashboard or login
return;
}
// Load data if user is admin
if (user && isAdmin) {
loadMediaData();
}
}, [user, userProfile, authLoading, navigate, isAdmin, loadMediaData]); // Depend on auth states, navigate, loadMediaData
// Reload data when filters change
useEffect(() => {
if (user && isAdmin) {
loadMediaData();
}
}, [filterType, filterPublished, user, isAdmin, loadMediaData]);
const handleAddMedia = () => {
setMediaToEdit(null); // Ensure we are adding, not editing
setIsAddModalOpen(true);
};
const handleEditMedia = (media) => {
setMediaToEdit(media); // Set media to edit
setIsEditModalOpen(true);
};
const handleDeleteMedia = async (mediaId) => {
if (!window.confirm('Вы уверены, что хотите удалить этот контент? Это также удалит все связанные с ним рецензии и сезоны!')) {
return;
}
try {
setLoading(true); // Show main loading indicator
setError(null); // Clear main error
await deleteMedia(mediaId);
loadMediaData(); // Reload the list after deletion
} catch (err) {
console.error('Error deleting media:', err);
setError('Не удалось удалить контент.');
setLoading(false); // Hide loading on error
}
};
const handleFormSuccess = () => {
setIsAddModalOpen(false);
setIsEditModalOpen(false);
setMediaToEdit(null);
loadMediaData(); // Reload the list after add/edit
};
const handleFormCancel = () => {
setIsAddModalOpen(false);
setIsEditModalOpen(false);
setMediaToEdit(null);
};
// Function to navigate to seasons page
const handleManageSeasons = (mediaId) => {
navigate(`/admin/media/${mediaId}/seasons`);
};
if (authLoading || loading) {
return <div className="flex justify-center items-center h-screen text-campfire-light">Загрузка...</div>;
}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
// If not admin, redirect happened in useEffect, this shouldn't be reached but as a fallback:
if (!isAdmin) {
return null;
}
return (
<div> {/* Removed container-custom and pt-20 */}
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-bold text-campfire-light">Управление контентом</h2>
<button
onClick={handleAddMedia}
className="btn-primary flex items-center space-x-2"
>
<FaPlus size={18} />
<span>Добавить контент</span>
</button>
</div>
{/* Filters */}
<div className="flex space-x-4 mb-6">
{/* Type Filter */}
<div>
<label htmlFor="filterType" className="block text-sm font-medium text-campfire-light mb-1">
Тип:
</label>
<select
id="filterType"
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="input px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
>
<option value="">Все типы</option>
{Object.entries(mediaTypes).map(([key, value]) => (
<option key={key} value={key}>{value.label}</option>
))}
</select>
</div>
{/* Published Filter */}
<div>
<label htmlFor="filterPublished" className="block text-sm font-medium text-campfire-light mb-1">
Статус публикации:
</label>
<select
id="filterPublished"
value={filterPublished}
onChange={(e) => setFilterPublished(e.target.value)}
className="input px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
>
<option value="">Все</option>
<option value="true">Опубликовано</option>
<option value="false">Не опубликовано</option>
</select>
</div>
</div>
{mediaList.length === 0 ? (
<div className="text-center py-8 text-campfire-light">
Контент не найден. Добавьте первый элемент!
</div>
) : (
<div className="overflow-x-auto custom-scrollbar"> {/* Add overflow for responsiveness */}
<table className="min-w-full divide-y divide-campfire-ash/20">
<thead className="bg-campfire-dark">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider">
Название
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider">
Тип
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider">
Опубликовано
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider">
Популярное
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider">
Рейтинг
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider">
Обзоры
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Действия</span>
</th>
</tr>
</thead>
<tbody className="bg-campfire-charcoal divide-y divide-campfire-ash/20 text-campfire-light">
{mediaList.map((media) => (
<tr key={media.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-campfire-light">
{media.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{mediaTypes[media.type]?.label || media.type}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${media.is_published ? 'bg-status-success/20 text-status-success' : 'bg-status-error/20 text-status-error'}`}>
{media.is_published ? 'Да' : 'Нет'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${media.is_popular ? 'bg-status-success/20 text-status-success' : 'bg-status-error/20 text-status-error'}`}>
{media.is_popular ? 'Да' : 'Нет'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{media.average_rating ? media.average_rating.toFixed(1) : 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
{media.review_count ?? 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-4">
{/* Manage Seasons Button (only for TV/Anime) */}
{(media.type === 'tv' || media.type === 'anime') && (
<button
onClick={() => handleManageSeasons(media.id)}
className="text-campfire-amber hover:text-campfire-light transition-colors"
title="Управление сезонами"
>
<FaTv size={18} />
</button>
)}
{/* Edit Button */}
<button
onClick={() => handleEditMedia(media)}
className="text-campfire-amber hover:text-campfire-light transition-colors"
title="Редактировать"
>
<FaEdit size={18} />
</button>
{/* Delete Button */}
<button
onClick={() => handleDeleteMedia(media.id)}
className="text-status-error hover:text-red-400 transition-colors"
title="Удалить"
>
<FaTrashAlt size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add Media Modal */}
<Modal
isOpen={isAddModalOpen}
onClose={handleFormCancel}
title="Добавить новый контент"
size="lg" // Use lg size
>
<MediaForm onSuccess={handleFormSuccess} />
</Modal>
{/* Edit Media Modal */}
<Modal
isOpen={isEditModalOpen}
onClose={handleFormCancel}
title="Редактировать контент"
size="lg" // Use lg size
>
{/* Pass mediaToEdit to the form */}
<MediaForm media={mediaToEdit} onSuccess={handleFormSuccess} />
</Modal>
</div>
);
};
export default AdminMediaPage;

View File

@ -0,0 +1,507 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { getSeasons, createSeason, updateSeason, deleteSeason, getFileUrl, getSeasonsByMediaId, listMedia } from '../../services/pocketbaseService';
import { toast } from 'react-toastify';
import { FaEdit, FaTrash, FaPlus } from 'react-icons/fa';
import Modal from '../../components/common/Modal';
import { Navigate } from 'react-router-dom';
const AdminSeasonsPage = () => {
const { mediaId: urlMediaId } = useParams();
const navigate = useNavigate();
const { user, userProfile, loading: authLoading } = useAuth();
const [seasons, setSeasons] = useState([]);
const [mediaList, setMediaList] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSeason, setEditingSeason] = useState(null);
const [formData, setFormData] = useState({
title: '',
overview: '',
release_date: '',
season_number: '',
is_published: false,
media_id: urlMediaId || '',
poster: null
});
const [currentPosterUrl, setCurrentPosterUrl] = useState(null);
const [deleteExistingPoster, setDeleteExistingPoster] = useState(false);
useEffect(() => {
if (!user || userProfile?.role !== 'admin') {
navigate('/admin');
return;
}
loadMediaList();
if (urlMediaId) {
loadSeasons();
}
}, [user, userProfile, urlMediaId, navigate]);
const loadMediaList = async () => {
try {
setLoading(true);
const [tvData, animeData] = await Promise.all([
listMedia('tv', 1, 100, user, false, null),
listMedia('anime', 1, 100, user, false, null)
]);
const combinedMedia = [
...(tvData.data || []),
...(animeData.data || [])
];
setMediaList(combinedMedia);
setError(null);
} catch (err) {
console.error('Error loading media list:', err);
setError('Не удалось загрузить список медиа');
toast.error('Не удалось загрузить список медиа');
} finally {
setLoading(false);
}
};
const loadSeasons = async () => {
try {
setLoading(true);
const data = await getSeasonsByMediaId(urlMediaId, {
expand: 'media_id'
});
setSeasons(Array.isArray(data) ? data : []);
setError(null);
} catch (err) {
console.error('Error loading seasons:', err);
setError('Не удалось загрузить сезоны');
toast.error('Не удалось загрузить сезоны');
} finally {
setLoading(false);
}
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFormData({ ...formData, poster: file });
setDeleteExistingPoster(false);
}
};
const handleRemoveExistingPoster = () => {
setCurrentPosterUrl(null);
setDeleteExistingPoster(true);
setFormData({ ...formData, poster: null });
};
const handleOpenModal = (season = null) => {
if (season) {
setEditingSeason(season);
setFormData({
title: season.title || '',
overview: season.overview || '',
release_date: season.release_date || '',
season_number: season.season_number || '',
is_published: season.is_published || false,
media_id: season.media_id || urlMediaId || '',
poster: null
});
if (season.poster) {
setCurrentPosterUrl(getFileUrl(season, 'poster'));
} else {
setCurrentPosterUrl(null);
}
} else {
setEditingSeason(null);
setFormData({
title: '',
overview: '',
release_date: '',
season_number: '',
is_published: false,
media_id: urlMediaId || '',
poster: null
});
setCurrentPosterUrl(null);
}
setDeleteExistingPoster(false);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingSeason(null);
setFormData({
title: '',
overview: '',
release_date: '',
season_number: '',
is_published: false,
media_id: urlMediaId || '',
poster: null
});
setCurrentPosterUrl(null);
setDeleteExistingPoster(false);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (!formData.media_id) {
toast.error('Выберите медиа');
return;
}
const dataToSend = new FormData();
dataToSend.append('media_id', formData.media_id);
dataToSend.append('season_number', formData.season_number);
dataToSend.append('title', formData.title || '');
dataToSend.append('overview', formData.overview || '');
if (formData.release_date) {
dataToSend.append('release_date', formData.release_date);
}
dataToSend.append('is_published', formData.is_published);
if (formData.poster) {
dataToSend.append('poster', formData.poster);
} else if (deleteExistingPoster) {
dataToSend.append('poster', '');
}
if (editingSeason) {
await updateSeason(editingSeason.id, dataToSend);
toast.success('Сезон обновлен');
} else {
await createSeason(dataToSend);
toast.success('Сезон создан');
}
handleCloseModal();
loadSeasons();
} catch (err) {
console.error('Error saving season:', err);
toast.error('Не удалось сохранить сезон');
}
};
const handleDelete = async (seasonId) => {
if (!window.confirm('Вы уверены, что хотите удалить этот сезон?')) {
return;
}
try {
await deleteSeason(seasonId);
toast.success('Сезон удален');
loadSeasons();
} catch (err) {
console.error('Error deleting season:', err);
toast.error('Не удалось удалить сезон');
}
};
if (loading) {
return (
<div className="container-custom pt-8 text-campfire-light">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="container-custom pt-8 text-campfire-light">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
{error}
</div>
</div>
);
}
return (
<div className="container-custom pt-8 text-campfire-light">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{urlMediaId ? 'Управление сезонами' : 'Создание сезона'}
</h1>
<button
onClick={() => handleOpenModal()}
className="flex items-center px-4 py-2 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/80 transition-colors"
>
<FaPlus className="mr-2" />
Добавить сезон
</button>
</div>
{urlMediaId ? (
<div className="bg-campfire-darker rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-campfire-charcoal/50">
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Номер
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Медиа
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Название
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Описание
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Дата выхода
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Статус
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="divide-y divide-campfire-charcoal/30">
{Array.isArray(seasons) && seasons.map((season) => (
<tr key={season.id} className="hover:bg-campfire-charcoal/20">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-campfire-light">
{season.season_number}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-campfire-light">
{season.expand?.media_id?.title || 'Не указано'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-campfire-light">
{season.title}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-campfire-light/70">
{season.overview}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-light/70">
{season.release_date ? new Date(season.release_date).toLocaleDateString() : 'Не указана'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
season.is_published
? 'bg-green-500/20 text-green-500'
: 'bg-campfire-ash/20 text-campfire-ash'
}`}>
{season.is_published ? 'Опубликован' : 'Черновик'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleOpenModal(season)}
className="text-campfire-amber hover:text-campfire-light mr-4"
title="Редактировать сезон"
>
<FaEdit />
</button>
<button
onClick={() => handleDelete(season.id)}
className="text-red-500 hover:text-red-700"
title="Удалить сезон"
>
<FaTrash />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mediaList.length === 0 ? (
<div className="col-span-full text-center py-8 text-campfire-light">
Нет доступных медиа для создания сезонов
</div>
) : (
mediaList.map((media) => (
<div
key={media.id}
className="bg-campfire-charcoal rounded-lg overflow-hidden border border-campfire-ash/20 p-4 hover:border-campfire-amber/50 transition-colors cursor-pointer"
onClick={() => navigate(`/admin/media/${media.id}/seasons`)}
>
<h3 className="text-xl font-semibold text-campfire-amber mb-2">
{media.title}
</h3>
<div className="text-sm text-campfire-light/70 mb-2">
<p>Рейтинг: {media.average_rating ? media.average_rating.toFixed(1) : 'N/A'}</p>
<p>Обзоры: {media.review_count}</p>
</div>
<div className="flex space-x-2">
<span className={`px-2 py-1 text-xs rounded-full ${
media.is_published
? 'bg-green-500/20 text-green-500'
: 'bg-red-500/20 text-red-500'
}`}>
{media.is_published ? 'Опубликовано' : 'Черновик'}
</span>
{media.is_popular && (
<span className="px-2 py-1 text-xs rounded-full bg-campfire-amber/20 text-campfire-amber">
Популярное
</span>
)}
</div>
</div>
))
)}
</div>
)}
<Modal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={editingSeason ? 'Редактировать сезон' : 'Добавить сезон'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="media_id" className="block text-sm font-medium text-campfire-light mb-1">
Медиа
</label>
<select
id="media_id"
value={formData.media_id}
onChange={(e) => setFormData({ ...formData, media_id: e.target.value })}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
required
disabled={!!urlMediaId}
>
<option value="">Выберите медиа</option>
{mediaList.map((media) => (
<option key={media.id} value={media.id}>
{media.title}
</option>
))}
</select>
</div>
<div>
<label htmlFor="season_number" className="block text-sm font-medium text-campfire-light mb-1">
Номер сезона
</label>
<input
type="number"
id="season_number"
value={formData.season_number}
onChange={(e) => setFormData({ ...formData, season_number: e.target.value })}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
required
/>
</div>
<div>
<label htmlFor="title" className="block text-sm font-medium text-campfire-light mb-1">
Название
</label>
<input
type="text"
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
/>
</div>
<div>
<label htmlFor="overview" className="block text-sm font-medium text-campfire-light mb-1">
Описание
</label>
<textarea
id="overview"
value={formData.overview}
onChange={(e) => setFormData({ ...formData, overview: e.target.value })}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
rows="3"
/>
</div>
<div>
<label htmlFor="release_date" className="block text-sm font-medium text-campfire-light mb-1">
Дата выхода
</label>
<input
type="date"
id="release_date"
value={formData.release_date}
onChange={(e) => setFormData({ ...formData, release_date: e.target.value })}
className="w-full p-2 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light"
/>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.is_published}
onChange={(e) => setFormData({ ...formData, is_published: e.target.checked })}
className="rounded border-campfire-ash/30 text-campfire-amber focus:ring-campfire-amber"
/>
<span className="text-sm text-campfire-light">Опубликовать</span>
</label>
</div>
<div>
<label htmlFor="poster" className="block text-sm font-medium text-campfire-light mb-1">
Постер сезона
</label>
{currentPosterUrl && !deleteExistingPoster && (
<div className="mb-4">
<p className="text-sm text-campfire-ash mb-2">Текущий постер:</p>
<img src={currentPosterUrl} alt="Current Poster" className="w-24 h-auto rounded-md mb-2" />
<button
type="button"
onClick={handleRemoveExistingPoster}
className="text-red-500 hover:underline text-sm"
>
Удалить текущий постер
</button>
</div>
)}
<input
type="file"
id="poster"
name="poster"
onChange={handleFileChange}
className="w-full text-campfire-light text-sm
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-campfire-amber file:text-campfire-dark
hover:file:bg-campfire-amber/80 cursor-pointer"
accept="image/*"
/>
</div>
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={handleCloseModal}
className="btn-secondary"
>
Отмена
</button>
<button
type="submit"
className="btn-primary"
>
{editingSeason ? 'Сохранить' : 'Создать'}
</button>
</div>
</form>
</Modal>
</div>
);
};
export default AdminSeasonsPage;

View File

@ -0,0 +1,183 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getSuggestions, updateSuggestion, deleteSuggestion } from '../../services/pocketbaseService';
import { toast } from 'react-toastify';
import { FaCheck, FaTimes, FaTrash } from 'react-icons/fa';
import { Navigate } from 'react-router-dom';
const AdminSuggestionsPage = () => {
const { user } = useAuth();
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!user?.role === 'admin') {
return <Navigate to="/" replace />;
}
loadSuggestions();
}, [user]);
const loadSuggestions = async () => {
try {
setLoading(true);
const data = await getSuggestions();
setSuggestions(Array.isArray(data) ? data : []);
setError(null);
} catch (err) {
console.error('Error loading suggestions:', err);
setError('Не удалось загрузить предложения');
toast.error('Не удалось загрузить предложения');
} finally {
setLoading(false);
}
};
const handleStatusChange = async (id, newStatus) => {
try {
await updateSuggestion(id, { status: newStatus });
toast.success('Статус предложения обновлен');
loadSuggestions();
} catch (err) {
console.error('Error updating suggestion:', err);
toast.error('Не удалось обновить статус предложения');
}
};
const handleDelete = async (id) => {
if (!window.confirm('Вы уверены, что хотите удалить это предложение?')) {
return;
}
try {
await deleteSuggestion(id);
toast.success('Предложение удалено');
loadSuggestions();
} catch (err) {
console.error('Error deleting suggestion:', err);
toast.error('Не удалось удалить предложение');
}
};
if (loading) {
return (
<div className="container-custom pt-8 text-campfire-light">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="container-custom pt-8 text-campfire-light">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
{error}
</div>
</div>
);
}
return (
<div className="container-custom pt-8 text-campfire-light">
<h1 className="text-3xl font-bold mb-6">Управление предложениями</h1>
<div className="bg-campfire-darker rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-campfire-charcoal/50">
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Название
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Категория
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Пользователь
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Статус
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Дата
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light/70 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="divide-y divide-campfire-charcoal/30">
{Array.isArray(suggestions) && suggestions.map((suggestion) => (
<tr key={suggestion.id} className="hover:bg-campfire-charcoal/20">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-campfire-light">
{suggestion.title}
</div>
<div className="text-sm text-campfire-light/70">
{suggestion.description}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-campfire-amber/20 text-campfire-amber">
{suggestion.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-light/70">
{suggestion.user?.username || 'Неизвестный пользователь'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
suggestion.status === 'approved'
? 'bg-green-500/20 text-green-500'
: suggestion.status === 'rejected'
? 'bg-red-500/20 text-red-500'
: 'bg-yellow-500/20 text-yellow-500'
}`}>
{suggestion.status === 'approved'
? 'Одобрено'
: suggestion.status === 'rejected'
? 'Отклонено'
: 'На рассмотрении'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-light/70">
{new Date(suggestion.created).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{suggestion.status === 'pending' && (
<div className="flex space-x-2">
<button
onClick={() => handleStatusChange(suggestion.id, 'approved')}
className="text-green-500 hover:text-green-400"
title="Одобрить"
>
<FaCheck />
</button>
<button
onClick={() => handleStatusChange(suggestion.id, 'rejected')}
className="text-red-500 hover:text-red-400"
title="Отклонить"
>
<FaTimes />
</button>
</div>
)}
<button
onClick={() => handleDelete(suggestion.id)}
className="text-red-500 hover:text-red-400 ml-2"
title="Удалить"
>
<FaTrash />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default AdminSuggestionsPage;

View File

@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import { pb } from '../../services/pocketbaseService';
import { toast } from 'react-hot-toast';
import { FaSpinner, FaCheck, FaTimes, FaClock } from 'react-icons/fa';
const AdminSupportPage = () => {
const [tickets, setTickets] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedTicket, setSelectedTicket] = useState(null);
const [responseMessage, setResponseMessage] = useState('');
const [filter, setFilter] = useState('all');
const [messageText, setMessageText] = useState('');
useEffect(() => {
fetchTickets();
}, [filter]);
const fetchTickets = async () => {
try {
setLoading(true);
let filterQuery = '';
if (filter !== 'all') {
filterQuery = `status = "${filter}"`;
}
const records = await pb.collection('support_tickets').getList(1, 50, {
sort: '-created',
expand: 'user_id',
filter: filterQuery
});
setTickets(records.items);
} catch (error) {
console.error('Ошибка при загрузке тикетов:', error);
toast.error('Не удалось загрузить тикеты');
} finally {
setLoading(false);
}
};
const handleStatusChange = async (ticketId, newStatus) => {
try {
await pb.collection('support_tickets').update(ticketId, {
status: newStatus
});
// Обновляем статус в локальном состоянии
setTickets(prevTickets =>
prevTickets.map(ticket =>
ticket.id === ticketId
? { ...ticket, status: newStatus }
: ticket
)
);
// Обновляем статус в выбранном тикете
if (selectedTicket?.id === ticketId) {
setSelectedTicket(prev => ({ ...prev, status: newStatus }));
}
toast.success('Статус тикета обновлен');
} catch (error) {
console.error('Ошибка при обновлении статуса:', error);
toast.error('Не удалось обновить статус');
}
};
const handleSendMessage = async (ticketId) => {
if (!messageText.trim()) return;
try {
setLoading(true);
const updatedTicket = await pb.collection('support_tickets').update(ticketId, {
admin_notes: messageText,
status: 'in_progress'
});
// Обновляем локальное состояние
setTickets(prevTickets =>
prevTickets.map(ticket =>
ticket.id === ticketId
? { ...ticket, admin_notes: messageText, status: 'in_progress' }
: ticket
)
);
// Обновляем выбранный тикет
if (selectedTicket?.id === ticketId) {
setSelectedTicket(prev => ({
...prev,
admin_notes: messageText,
status: 'in_progress'
}));
}
setMessageText('');
toast.success('Сообщение отправлено');
} catch (error) {
console.error('Error sending message:', error);
toast.error('Не удалось отправить сообщение');
} finally {
setLoading(false);
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'open':
return <FaClock className="text-status-warning" />;
case 'in_progress':
return <FaSpinner className="text-status-info animate-spin" />;
case 'closed':
return <FaCheck className="text-status-success" />;
default:
return null;
}
};
const getStatusLabel = (status) => {
switch (status) {
case 'open':
return 'Открыт';
case 'in_progress':
return 'В работе';
case 'closed':
return 'Закрыт';
default:
return status;
}
};
return (
<div className="container-custom py-24">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-campfire-light">Управление тикетами</h1>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="bg-campfire-dark border border-campfire-ash/30 rounded-md px-4 py-2 text-campfire-light"
>
<option value="all">Все тикеты</option>
<option value="open">Открытые</option>
<option value="in_progress">В работе</option>
<option value="closed">Закрытые</option>
</select>
</div>
{loading ? (
<div className="flex justify-center items-center py-12">
<FaSpinner className="animate-spin text-4xl text-campfire-amber" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Список тикетов */}
<div className="space-y-4">
{tickets.map(ticket => (
<div
key={ticket.id}
className={`bg-campfire-charcoal rounded-lg p-4 border ${
selectedTicket?.id === ticket.id
? 'border-campfire-amber'
: 'border-campfire-ash/20'
} cursor-pointer hover:border-campfire-amber/50 transition-colors`}
onClick={() => setSelectedTicket(ticket)}
>
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-campfire-light font-semibold">{ticket.subject}</h3>
<p className="text-campfire-ash text-sm">
От: {ticket.expand?.user_id?.username || 'Неизвестный пользователь'}
</p>
</div>
<div className="flex items-center space-x-2">
{getStatusIcon(ticket.status)}
<span className="text-sm text-campfire-ash">
{getStatusLabel(ticket.status)}
</span>
</div>
</div>
<p className="text-campfire-ash text-sm line-clamp-2">{ticket.message}</p>
<div className="mt-2 text-xs text-campfire-ash">
{new Date(ticket.created).toLocaleString()}
</div>
</div>
))}
</div>
{/* Детали тикета */}
{selectedTicket && (
<div className="bg-campfire-charcoal rounded-lg p-6 border border-campfire-ash/20">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-xl font-bold text-campfire-light mb-2">
{selectedTicket.subject}
</h2>
<p className="text-campfire-ash">
От: {selectedTicket.expand?.user_id?.username || 'Неизвестный пользователь'}
</p>
</div>
<div className="flex items-center space-x-2">
{getStatusIcon(selectedTicket.status)}
<span className="text-campfire-ash">
{getStatusLabel(selectedTicket.status)}
</span>
</div>
</div>
<div className="mb-6">
<h4 className="text-campfire-light font-semibold mb-2">Сообщение пользователя:</h4>
<p className="text-campfire-ash whitespace-pre-wrap">{selectedTicket.message}</p>
</div>
{selectedTicket.admin_notes && (
<div className="mb-6">
<h4 className="text-campfire-light font-semibold mb-2">Ответ поддержки:</h4>
<p className="text-campfire-ash whitespace-pre-wrap">{selectedTicket.admin_notes}</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-campfire-light mb-2">Ответить:</label>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
className="w-full bg-campfire-dark border border-campfire-ash/30 rounded-md px-4 py-2 text-campfire-light focus:outline-none focus:border-campfire-amber"
rows="4"
placeholder="Введите ваш ответ..."
/>
</div>
<div className="flex justify-between items-center">
<div className="space-x-2">
<button
onClick={() => handleStatusChange(selectedTicket.id, 'in_progress')}
disabled={selectedTicket.status === 'in_progress'}
className="btn-secondary disabled:opacity-50"
>
В работу
</button>
<button
onClick={() => handleStatusChange(selectedTicket.id, 'closed')}
disabled={selectedTicket.status === 'closed'}
className="btn-secondary disabled:opacity-50"
>
Закрыть
</button>
</div>
<button
onClick={() => handleSendMessage(selectedTicket.id)}
className="btn-primary"
>
Отправить сообщение
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default AdminSupportPage;

View File

@ -0,0 +1,155 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getUsers, updateUser, deleteUser, getFileUrl } from '../../services/pocketbaseService';
import { toast } from 'react-toastify';
import { FaUser, FaUserShield, FaTrash } from 'react-icons/fa';
import { Navigate } from 'react-router-dom';
const AdminUsersPage = () => {
const { user } = useAuth();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!user?.role === 'admin') {
return <Navigate to="/" replace />;
}
loadUsers();
}, [user]);
console.log(user)
const loadUsers = async () => {
try {
setLoading(true);
const data = await getUsers();
setUsers(Array.isArray(data) ? data : []);
} catch (err) {
setError('Ошибка при загрузке пользователей');
toast.error('Не удалось загрузить пользователей');
} finally {
setLoading(false);
}
};
const handleRoleChange = async (userId, isAdmin) => {
try {
await updateUser(userId, { role: isAdmin ? 'admin' : 'user' });
toast.success('Роль пользователя обновлена');
loadUsers();
} catch (err) {
toast.error('Не удалось обновить роль пользователя');
}
};
const handleDelete = async (userId) => {
if (!window.confirm('Вы уверены, что хотите удалить этого пользователя?')) {
return;
}
try {
await deleteUser(userId);
toast.success('Пользователь удален');
loadUsers();
} catch (err) {
toast.error('Не удалось удалить пользователя');
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
return (
<div className="text-center text-red-500">
<p>{error}</p>
</div>
);
}
return (
<div className="container-custom">
<h1 className="text-3xl font-bold mb-6">Управление пользователями</h1>
<div className="bg-campfire-darker rounded-lg shadow-lg overflow-hidden">
<table className="min-w-full divide-y divide-campfire-ash">
<thead className="bg-campfire-charcoal">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light uppercase tracking-wider">
Пользователь
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light uppercase tracking-wider">
Роль
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-campfire-light uppercase tracking-wider">
Дата регистрации
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-campfire-light uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="bg-campfire-darker divide-y divide-campfire-ash">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<img
className="h-10 w-10 rounded-full object-cover"
src={getFileUrl(user, 'profile_picture') || 'https://pocketbase.campfiregg.ru/api/files/_pb_users_auth_/g520s25pzm0t6e1/photo_2025_05_17_22_26_21_g2bi9umsuu.jpg'}
alt={user.username}
/>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-campfire-light">
{user.username}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-campfire-light">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'admin' ? 'bg-campfire-amber text-campfire-dark' : 'bg-campfire-charcoal text-campfire-light'
}`}>
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-light">
{new Date(user.created).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleRoleChange(user.id, user.role !== 'admin')}
className="text-campfire-amber hover:text-campfire-light mr-4"
title={user.role === 'admin' ? 'Снять права администратора' : 'Назначить администратором'}
>
{user.role === 'admin' ? <FaUser /> : <FaUserShield />}
</button>
<button
onClick={() => handleDelete(user.id)}
className="text-red-500 hover:text-red-700"
title="Удалить пользователя"
>
<FaTrash />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default AdminUsersPage;

View File

@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import Stepper from '../../components/reactbits/Components/Stepper/Stepper';
const RegisterPage = () => {
const navigate = useNavigate();
const { register } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
avatar: null
});
const [error, setError] = useState('');
const steps = [
{
title: 'Основная информация',
content: (
<div className="space-y-4">
<div>
<label className="block text-campfire-light mb-2">Имя пользователя</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full p-2 rounded bg-campfire-darker text-campfire-light border border-campfire-ash"
/>
</div>
<div>
<label className="block text-campfire-light mb-2">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full p-2 rounded bg-campfire-darker text-campfire-light border border-campfire-ash"
/>
</div>
</div>
)
},
{
title: 'Безопасность',
content: (
<div className="space-y-4">
<div>
<label className="block text-campfire-light mb-2">Пароль</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full p-2 rounded bg-campfire-darker text-campfire-light border border-campfire-ash"
/>
</div>
<div>
<label className="block text-campfire-light mb-2">Подтвердите пароль</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full p-2 rounded bg-campfire-darker text-campfire-light border border-campfire-ash"
/>
</div>
</div>
)
},
{
title: 'Аватар',
content: (
<div className="space-y-4">
<div>
<label className="block text-campfire-light mb-2">Загрузите аватар</label>
<input
type="file"
accept="image/*"
onChange={(e) => setFormData({ ...formData, avatar: e.target.files[0] })}
className="w-full p-2 rounded bg-campfire-darker text-campfire-light border border-campfire-ash"
/>
</div>
</div>
)
}
];
const handleSubmit = async () => {
try {
if (formData.password !== formData.confirmPassword) {
setError('Пароли не совпадают');
return;
}
await register(formData.username, formData.email, formData.password, formData.avatar);
navigate('/');
} catch (err) {
setError(err.message);
}
};
return (
<div className="min-h-screen bg-campfire-charcoal flex items-center justify-center py-12">
<div className="w-full max-w-md p-8 bg-campfire-darker rounded-lg shadow-lg">
<h1 className="text-2xl font-bold text-campfire-light mb-8 text-center">Регистрация</h1>
<Stepper
steps={steps}
currentStep={currentStep}
onStepChange={setCurrentStep}
onComplete={handleSubmit}
className="mb-8"
activeColor="#f59e0b"
completedColor="#f59e0b"
inactiveColor="#4b5563"
lineColor="#4b5563"
showNumbers={true}
showTitles={true}
showContent={true}
showNavigation={true}
showProgress={true}
showSteps={true}
showStepContent={true}
showStepTitle={true}
showStepNumber={true}
showStepIcon={true}
showStepLine={true}
showStepProgress={true}
showStepNavigation={true}
showStepComplete={true}
showStepError={true}
showStepWarning={true}
showStepInfo={true}
showStepSuccess={true}
showStepDisabled={true}
showStepHidden={true}
showStepOptional={true}
showStepRequired={true}
showStepValidation={true}
showStepValidationError={true}
showStepValidationWarning={true}
showStepValidationInfo={true}
showStepValidationSuccess={true}
showStepValidationDisabled={true}
showStepValidationHidden={true}
showStepValidationOptional={true}
showStepValidationRequired={true}
/>
{error && (
<div className="text-red-500 text-center mb-4">{error}</div>
)}
</div>
</div>
);
};
export default RegisterPage;

View File

@ -0,0 +1,65 @@
import React from 'react';
const PrivacyPolicyPage = () => {
return (
<div className="container-custom py-24">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-campfire-light mb-8">Политика конфиденциальности</h1>
<div className="prose prose-invert max-w-none">
<h2>1. Общие положения</h2>
<p>
Настоящая политика конфиденциальности определяет порядок обработки и защиты персональных данных
CampFire (далее Оператор).
</p>
<h2>2. Сбор информации</h2>
<p>
Мы собираем следующие виды информации:
</p>
<ul>
<li>Информация, предоставляемая при регистрации (имя пользователя, email)</li>
<li>Информация о вашей активности на сайте</li>
<li>Техническая информация о вашем устройстве и браузере</li>
</ul>
<h2>3. Использование информации</h2>
<p>
Собранная информация используется для:
</p>
<ul>
<li>Предоставления и улучшения наших услуг</li>
<li>Обеспечения безопасности пользователей</li>
<li>Коммуникации с пользователями</li>
</ul>
<h2>4. Защита информации</h2>
<p>
Мы принимаем необходимые меры для защиты вашей персональной информации от несанкционированного
доступа, изменения, раскрытия или уничтожения.
</p>
<h2>5. Cookies</h2>
<p>
Мы используем cookies для улучшения пользовательского опыта. Вы можете отключить использование
cookies в настройках вашего браузера.
</p>
<h2>6. Изменения в политике конфиденциальности</h2>
<p>
Мы оставляем за собой право вносить изменения в настоящую политику конфиденциальности.
При внесении существенных изменений мы уведомим пользователей через сайт или по email.
</p>
<h2>7. Контактная информация</h2>
<p>
Если у вас есть вопросы относительно нашей политики конфиденциальности, пожалуйста,
свяжитесь с нами через форму обратной связи.
</p>
</div>
</div>
</div>
);
};
export default PrivacyPolicyPage;

Some files were not shown because too many files have changed in this diff Show More