Initial commit
This commit is contained in:
commit
7c111dd711
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal 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
187
README.md
Normal 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
41
eslint.config.js
Normal 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
14
index.html
Normal 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
16
jsrepo.json
Normal 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
6590
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal 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
186
pb_schema.txt
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
1
public/icon.png
Normal file
1
public/icon.png
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
1
public/logo.png
Normal file
1
public/logo.png
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
1
public/placeholder-poster.jpg
Normal file
1
public/placeholder-poster.jpg
Normal 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
1
public/vite.svg
Normal 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
42
src/App.css
Normal 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
120
src/App.jsx
Normal 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
BIN
src/assets/404.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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 |
139
src/components/LatestReviewsMarquee.jsx
Normal file
139
src/components/LatestReviewsMarquee.jsx
Normal 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;
|
151
src/components/admin/AchievementForm.jsx
Normal file
151
src/components/admin/AchievementForm.jsx
Normal 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;
|
46
src/components/admin/AdminLayout.jsx
Normal file
46
src/components/admin/AdminLayout.jsx
Normal 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;
|
108
src/components/admin/AdminSidebar.jsx
Normal file
108
src/components/admin/AdminSidebar.jsx
Normal 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;
|
713
src/components/admin/MediaForm.jsx
Normal file
713
src/components/admin/MediaForm.jsx
Normal 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;
|
29
src/components/auth/AdminRoute.jsx
Normal file
29
src/components/auth/AdminRoute.jsx
Normal 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;
|
26
src/components/auth/AuthRoute.jsx
Normal file
26
src/components/auth/AuthRoute.jsx
Normal 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
|
31
src/components/auth/GuestRoute.jsx
Normal file
31
src/components/auth/GuestRoute.jsx
Normal 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;
|
49
src/components/common/Modal.jsx
Normal file
49
src/components/common/Modal.jsx
Normal 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;
|
53
src/components/home/FeaturedMedia.jsx
Normal file
53
src/components/home/FeaturedMedia.jsx
Normal 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;
|
44
src/components/home/GridSection.jsx
Normal file
44
src/components/home/GridSection.jsx
Normal 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;
|
20
src/components/home/HeroSection.jsx
Normal file
20
src/components/home/HeroSection.jsx
Normal 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;
|
40
src/components/home/HomeContent.jsx
Normal file
40
src/components/home/HomeContent.jsx
Normal 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;
|
87
src/components/home/StatsSection.jsx
Normal file
87
src/components/home/StatsSection.jsx
Normal 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;
|
94
src/components/layout/AdminSidebar.jsx
Normal file
94
src/components/layout/AdminSidebar.jsx
Normal 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;
|
198
src/components/layout/Footer.jsx
Normal file
198
src/components/layout/Footer.jsx
Normal 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>
|
||||||
|
© {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;
|
390
src/components/layout/Header.jsx
Normal file
390
src/components/layout/Header.jsx
Normal 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;
|
34
src/components/layout/Layout.jsx
Normal file
34
src/components/layout/Layout.jsx
Normal 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;
|
79
src/components/media/CustomMediaCarousel.jsx
Normal file
79
src/components/media/CustomMediaCarousel.jsx
Normal 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;
|
89
src/components/media/MediaCard.jsx
Normal file
89
src/components/media/MediaCard.jsx
Normal 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;
|
30
src/components/media/MediaCarousel.jsx
Normal file
30
src/components/media/MediaCarousel.jsx
Normal 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;
|
72
src/components/navigation/ProfileMenu.jsx
Normal file
72
src/components/navigation/ProfileMenu.jsx
Normal 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;
|
37
src/components/profile/ProfileHeader.jsx
Normal file
37
src/components/profile/ProfileHeader.jsx
Normal 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;
|
159
src/components/reactbits/Animations/ClickSpark/ClickSpark.jsx
Normal file
159
src/components/reactbits/Animations/ClickSpark/ClickSpark.jsx
Normal 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;
|
105
src/components/reactbits/Backgrounds/GridMotion/GridMotion.jsx
Normal file
105
src/components/reactbits/Backgrounds/GridMotion/GridMotion.jsx
Normal 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;
|
44
src/components/reactbits/Components/Dock/Dock.css
Normal file
44
src/components/reactbits/Components/Dock/Dock.css
Normal 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);
|
||||||
|
}
|
185
src/components/reactbits/Components/Dock/Dock.jsx
Normal file
185
src/components/reactbits/Components/Dock/Dock.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
280
src/components/reactbits/Components/Stepper/Stepper.jsx
Normal file
280
src/components/reactbits/Components/Stepper/Stepper.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
232
src/components/reactbits/Components/TiltedCard/TiltedCard.jsx
Normal file
232
src/components/reactbits/Components/TiltedCard/TiltedCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
103
src/components/reactbits/TextAnimations/CountUp/CountUp.jsx
Normal file
103
src/components/reactbits/TextAnimations/CountUp/CountUp.jsx
Normal 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} />;
|
||||||
|
}
|
205
src/components/reactbits/TextAnimations/FuzzyText/FuzzyText.jsx
Normal file
205
src/components/reactbits/TextAnimations/FuzzyText/FuzzyText.jsx
Normal 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;
|
@ -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;
|
@ -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: [],
|
||||||
|
// };
|
144
src/components/reactbits/TextAnimations/TrueFocus/TrueFocus.jsx
Normal file
144
src/components/reactbits/TextAnimations/TrueFocus/TrueFocus.jsx
Normal 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;
|
38
src/components/reviews/FlameRatingInput.jsx
Normal file
38
src/components/reviews/FlameRatingInput.jsx
Normal 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;
|
54
src/components/reviews/LatestReviewsMarquee.jsx
Normal file
54
src/components/reviews/LatestReviewsMarquee.jsx
Normal 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;
|
127
src/components/reviews/LikeButton.jsx
Normal file
127
src/components/reviews/LikeButton.jsx
Normal 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;
|
141
src/components/reviews/RatingChart.jsx
Normal file
141
src/components/reviews/RatingChart.jsx
Normal 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;
|
137
src/components/reviews/ReviewCard.jsx
Normal file
137
src/components/reviews/ReviewCard.jsx
Normal 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;
|
522
src/components/reviews/ReviewForm.jsx
Normal file
522
src/components/reviews/ReviewForm.jsx
Normal 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;
|
256
src/components/reviews/ReviewItem.jsx
Normal file
256
src/components/reviews/ReviewItem.jsx
Normal 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;
|
31
src/components/reviews/ReviewList.jsx
Normal file
31
src/components/reviews/ReviewList.jsx
Normal 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;
|
119
src/components/suggestions/SuggestCardWidget.jsx
Normal file
119
src/components/suggestions/SuggestCardWidget.jsx
Normal 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;
|
95
src/components/support/SupportTicketForm.jsx
Normal file
95
src/components/support/SupportTicketForm.jsx
Normal 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;
|
13
src/components/ui/AlphaBadge.jsx
Normal file
13
src/components/ui/AlphaBadge.jsx
Normal 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;
|
11
src/components/ui/AlphaBanner.jsx
Normal file
11
src/components/ui/AlphaBanner.jsx
Normal 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;
|
19
src/components/ui/AlphaVersion.jsx
Normal file
19
src/components/ui/AlphaVersion.jsx
Normal 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;
|
22
src/components/ui/Logo.jsx
Normal file
22
src/components/ui/Logo.jsx
Normal 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;
|
103
src/components/ui/SearchBar.jsx
Normal file
103
src/components/ui/SearchBar.jsx
Normal 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;
|
54
src/components/ui/SearchResults.jsx
Normal file
54
src/components/ui/SearchResults.jsx
Normal 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;
|
13
src/components/ui/TrueFocus.jsx
Normal file
13
src/components/ui/TrueFocus.jsx
Normal 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;
|
271
src/contexts/AuthContext.jsx
Normal file
271
src/contexts/AuthContext.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
40
src/contexts/ClickSparkContext.jsx
Normal file
40
src/contexts/ClickSparkContext.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
77
src/contexts/MediaContext.jsx
Normal file
77
src/contexts/MediaContext.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
36
src/contexts/ProfileActionsContext.jsx
Normal file
36
src/contexts/ProfileActionsContext.jsx
Normal 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
272
src/index.css
Normal 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
20
src/main.jsx
Normal 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: Приложение отрендерено');
|
324
src/pages/AdminMediaPage.jsx
Normal file
324
src/pages/AdminMediaPage.jsx
Normal 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;
|
567
src/pages/AdminSeasonsPage.jsx
Normal file
567
src/pages/AdminSeasonsPage.jsx
Normal 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
331
src/pages/CatalogPage.jsx
Normal 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
110
src/pages/DiscoverPage.jsx
Normal 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;
|
39
src/pages/ForgotPasswordPage.jsx
Normal file
39
src/pages/ForgotPasswordPage.jsx
Normal 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
467
src/pages/HomePage.jsx
Normal 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
154
src/pages/LoginPage.jsx
Normal 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;
|
585
src/pages/MediaOverviewPage.jsx
Normal file
585
src/pages/MediaOverviewPage.jsx
Normal 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
|
40
src/pages/NotFoundPage.jsx
Normal file
40
src/pages/NotFoundPage.jsx
Normal 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
896
src/pages/ProfilePage.jsx
Normal 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;
|
433
src/pages/ProfileSettingsPage.jsx
Normal file
433
src/pages/ProfileSettingsPage.jsx
Normal 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
210
src/pages/RatingPage.jsx
Normal 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
249
src/pages/RegisterPage.jsx
Normal 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
410
src/pages/SupportPage.jsx
Normal 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;
|
143
src/pages/admin/AdminAchievementsPage.jsx
Normal file
143
src/pages/admin/AdminAchievementsPage.jsx
Normal 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;
|
116
src/pages/admin/AdminDashboard.jsx
Normal file
116
src/pages/admin/AdminDashboard.jsx
Normal 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;
|
77
src/pages/admin/AdminMediaCreatePage.jsx
Normal file
77
src/pages/admin/AdminMediaCreatePage.jsx
Normal 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;
|
116
src/pages/admin/AdminMediaEditPage.jsx
Normal file
116
src/pages/admin/AdminMediaEditPage.jsx
Normal 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;
|
309
src/pages/admin/AdminMediaPage.jsx
Normal file
309
src/pages/admin/AdminMediaPage.jsx
Normal 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;
|
507
src/pages/admin/AdminSeasonsPage.jsx
Normal file
507
src/pages/admin/AdminSeasonsPage.jsx
Normal 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;
|
183
src/pages/admin/AdminSuggestionsPage.jsx
Normal file
183
src/pages/admin/AdminSuggestionsPage.jsx
Normal 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;
|
263
src/pages/admin/AdminSupportPage.jsx
Normal file
263
src/pages/admin/AdminSupportPage.jsx
Normal 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;
|
155
src/pages/admin/AdminUsersPage.jsx
Normal file
155
src/pages/admin/AdminUsersPage.jsx
Normal 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;
|
158
src/pages/auth/RegisterPage.jsx
Normal file
158
src/pages/auth/RegisterPage.jsx
Normal 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;
|
65
src/pages/legal/PrivacyPolicyPage.jsx
Normal file
65
src/pages/legal/PrivacyPolicyPage.jsx
Normal 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
Loading…
Reference in New Issue
Block a user