Compare commits
No commits in common. "ef393743cbe7311794b60ff76d1cf98044492125" and "d5899c137dea04132e96e415e9db53759f91512d" have entirely different histories.
ef393743cb
...
d5899c137d
180
README.md
180
README.md
@ -1,50 +1,80 @@
|
||||
# CampFire Critics
|
||||
|
||||
CampFire Critics — это веб-приложение, построенное на React, Vite и Tailwind CSS, предназначенное для управления медиаконтентом и пользовательскими отзывами. Приложение включает аутентификацию, управление медиа и административные функции для модерации контента.
|
||||
CampFire Critics — это веб-приложение, созданное с использованием React, Vite и Tailwind CSS, предназначенное для каталогизации медиаконтента (фильмов, сериалов, игр, аниме) и управления пользовательскими отзывами. Приложение включает в себя систему аутентификации, подробные страницы медиа, профили пользователей, систему достижений, систему поддержки и административную панель для модерации контента и управления данными. В качестве бэкенда используется PocketBase.
|
||||
|
||||
## Функции
|
||||
|
||||
- **Аутентификация пользователей**: Вход, регистрация и управление профилем через Supabase.
|
||||
- **Управление медиа**: Просмотр, поиск и отображение медиаконтента с каруселью и карточным интерфейсом.
|
||||
- **Система отзывов**: Подача и просмотр отзывов с рейтинговым графиком для визуализации обратной связи.
|
||||
- **Админская панель**: Управление медиа и отзывами через AdminMediaPage.
|
||||
- **Адаптивный дизайн**: Построен с использованием Tailwind CSS для адаптивной верстки на разных устройствах.
|
||||
* **Аутентификация пользователей**: Полная система регистрации, входа, выхода и сброса пароля с использованием PocketBase Auth.
|
||||
* **Профили пользователей**: Просмотр профилей пользователей, отображение их статистики (количество рецензий, средний рейтинг, XP, уровень), списка достижений и витрины избранных рецензий. Возможность редактирования собственного профиля (описание, аватар, баннер, витрина).
|
||||
* **Каталог медиа**: Просмотр списка медиа с фильтрацией по типу (фильмы, сериалы, игры, аниме) и поиском.
|
||||
* **Страницы обзора медиа**: Подробные страницы для каждого медиа, включающие информацию о нем, список сезонов (для сериалов/аниме), а также раздел с пользовательскими рецензиями и рейтингами.
|
||||
* **Система рецензий**: Пользователи могут оставлять рецензии на медиа или отдельные сезоны, выставлять оценки по нескольким характеристикам, указывать прогресс просмотра/прохождения и отмечать спойлеры. Рецензии отображаются на страницах медиа/сезонов и в профилях пользователей.
|
||||
* **Система лайков рецензий**: Пользователи могут ставить лайки рецензиям других пользователей.
|
||||
* **Система XP и уровней**: Пользователи получают XP за создание рецензий и получение достижений, что повышает их уровень. Уровень и XP отображаются в профиле.
|
||||
* **Система достижений**: Пользователи могут получать достижения за различные действия. Список достижений отображается в профиле.
|
||||
* **Система поддержки**: Пользователи могут создавать тикеты поддержки с выбором категории и описанием проблемы.
|
||||
* **Административная панель**: Раздел для администраторов с возможностью управления медиа, пользователями, сезонами, достижениями и тикетами поддержки. Включает дашборд с общей статистикой.
|
||||
* **Адаптивный дизайн**: Приложение адаптировано для корректного отображения на различных устройствах с использованием Tailwind CSS.
|
||||
|
||||
## Технологии
|
||||
|
||||
- **Фронтенд**: React, Vite, Tailwind CSS
|
||||
- **Бэкенд**: Supabase (Аутентификация, База данных)
|
||||
- **Медиа API**: Интеграция с TMDB (The Movie Database) для получения данных о медиа
|
||||
- **Управление состоянием**: React Context API (AuthContext, MediaContext)
|
||||
* **Фронтенд**:
|
||||
* 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. **Клонируйте репозиторий**:
|
||||
1. **Клонирование репозитория** (стандартный шаг, не применимо в WebContainer):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/campfire-critics.git
|
||||
cd campfire-critics
|
||||
```
|
||||
|
||||
2. **Установите зависимости**:
|
||||
2. **Установка зависимостей**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Установите Supabase CLI** (если не установлен):
|
||||
|
||||
```bash
|
||||
npm install -g supabase
|
||||
```
|
||||
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.example` в `.env` и добавьте свои данные Supabase:
|
||||
* Создайте файл `.env` в корне проекта, если его нет.
|
||||
* Добавьте URL вашего PocketBase инстанса:
|
||||
```
|
||||
VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
VITE_POCKETBASE_URL=http://127.0.0.1:8090
|
||||
```
|
||||
Замените URL на адрес вашего запущенного PocketBase.
|
||||
|
||||
5. **Запустите сервер разработки**:
|
||||
|
||||
@ -52,37 +82,105 @@ CampFire Critics — это веб-приложение, построенное
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. **Запустите миграции Supabase** (если необходимо):
|
||||
```bash
|
||||
npm run supabase:start
|
||||
```
|
||||
Приложение будет доступно по адресу, указанному Vite (обычно `http://localhost:5173`).
|
||||
|
||||
## Структура проекта
|
||||
## Структура проекта и описание модулей
|
||||
|
||||
Проект организован следующим образом:
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.jsx # Основной компонент приложения
|
||||
├── main.jsx # Точка входа
|
||||
├── pages/ # Компоненты страниц (Главная, Вход, Админ и т.д.)
|
||||
├── components/ # Воспользуемые UI-компоненты
|
||||
├── contexts/ # Провайдеры React Context (Auth, Media)
|
||||
├── services/ # Утилиты API и сервисы (Supabase, TMDB)
|
||||
└── assets/ # Статические файлы
|
||||
├── 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
|
||||
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+
|
||||
* Node.js 18+
|
||||
* npm 8+
|
||||
* Запущенный инстанс PocketBase с настроенными коллекциями, RLS и хуками.
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<!-- 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 Critics Web Application</title>
|
||||
<title>CampFire мнеие</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
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"
|
||||
}
|
||||
}
|
2113
package-lock.json
generated
2113
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
56
package.json
56
package.json
@ -1,40 +1,46 @@
|
||||
{
|
||||
"name": "campfire-critics",
|
||||
"name": "campfirecritics",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neondatabase/serverless": "^0.9.0",
|
||||
"@supabase/supabase-js": "^2.39.3",
|
||||
"axios": "^1.6.7",
|
||||
"chart.js": "^4.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.9",
|
||||
"@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-dom": "^18.3.1",
|
||||
"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-router-dom": "^6.22.2",
|
||||
"supabase": "^2.22.12"
|
||||
"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": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.4.2"
|
||||
"@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
|
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,15 +0,0 @@
|
||||
# Проверяем, установлен ли Supabase CLI
|
||||
if (!(Get-Command supabase -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Supabase CLI не установлен. Запускаем установку..."
|
||||
.\setup-supabase.ps1
|
||||
}
|
||||
|
||||
# Инициализируем Supabase проект, если еще не инициализирован
|
||||
if (!(Test-Path "supabase\.temp")) {
|
||||
Write-Host "Инициализация Supabase проекта..."
|
||||
supabase init
|
||||
}
|
||||
|
||||
# Запускаем миграцию
|
||||
Write-Host "Запуск миграции базы данных..."
|
||||
supabase db push
|
@ -1,9 +0,0 @@
|
||||
# Установка Supabase CLI через scoop
|
||||
if (!(Get-Command scoop -ErrorAction SilentlyContinue)) {
|
||||
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
irm get.scoop.sh | iex
|
||||
}
|
||||
|
||||
# Установка Supabase CLI
|
||||
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
|
||||
scoop install supabase
|
128
src/App.jsx
128
src/App.jsx
@ -1,39 +1,119 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { MediaProvider } from "./contexts/MediaContext";
|
||||
import Header from "./components/layout/Header";
|
||||
import Footer from "./components/layout/Footer";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import MediaPage from "./pages/MediaPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
import NotFoundPage from "./pages/NotFoundPage";
|
||||
import AdminMediaPage from "./pages/AdminMediaPage";
|
||||
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]);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<MediaProvider>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<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">
|
||||
<main className="flex-grow pt-16 pb-32">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/media/:id" element={<MediaPage />} />
|
||||
<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="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/admin/media" element={<AdminMediaPage />} />
|
||||
<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>
|
||||
</MediaProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ProfileActionsProvider>
|
||||
<ClickSparkProvider>
|
||||
<AppContent />
|
||||
</ClickSparkProvider>
|
||||
</ProfileActionsProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
|
BIN
src/assets/404.webp
Normal file
BIN
src/assets/404.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 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;
|
@ -1,191 +1,713 @@
|
||||
import React, { useState } from 'react';
|
||||
import { supabase } from '../../services/supabase';
|
||||
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 MediaForm = ({ onSuccess, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
type: 'movie',
|
||||
overview: '',
|
||||
release_date: '',
|
||||
poster_url: '',
|
||||
backdrop_url: '',
|
||||
is_published: true
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
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);
|
||||
setError(null);
|
||||
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 {
|
||||
const { data, error } = await supabase
|
||||
.from('media')
|
||||
.insert([{
|
||||
...formData,
|
||||
created_by: (await supabase.auth.getUser()).data.user.id
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
onSuccess(data);
|
||||
} catch (error) {
|
||||
console.error('Error creating media:', error);
|
||||
setError(error.message);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-campfire-charcoal/80 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-campfire-dark rounded-lg shadow-xl max-w-2xl w-full p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-campfire-light">
|
||||
Добавить новый контент
|
||||
<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>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-campfire-ash hover:text-campfire-light"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg mb-6">
|
||||
{error}
|
||||
{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-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Название
|
||||
<label htmlFor="title" className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Название <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className="input w-full"
|
||||
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 className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Тип
|
||||
<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
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
className="input w-full"
|
||||
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="movie">Фильм</option>
|
||||
<option value="tv">Сериал</option>
|
||||
<option value="game">Игра</option>
|
||||
<option value="">Выберите тип</option>
|
||||
{Object.entries(mediaTypes).map(([key, value]) => (
|
||||
<option key={key} value={key}>{value.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Progress Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Описание
|
||||
<label htmlFor="progress_type" className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Тип отслеживания прогресса <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="overview"
|
||||
value={formData.overview}
|
||||
onChange={handleChange}
|
||||
className="input w-full h-32"
|
||||
<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 className="block text-sm font-medium text-campfire-light mb-1">
|
||||
<label htmlFor="release_date" className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Дата выхода
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="release_date"
|
||||
value={formData.release_date}
|
||||
onChange={handleChange}
|
||||
className="input w-full"
|
||||
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 className="block text-sm font-medium text-campfire-light mb-1">
|
||||
URL постера
|
||||
<label htmlFor={`char-label-${char.id}`} className="block text-xs font-medium text-campfire-ash mb-1">
|
||||
Название (для пользователей)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="poster_url"
|
||||
value={formData.poster_url}
|
||||
onChange={handleChange}
|
||||
className="input w-full"
|
||||
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>
|
||||
<label className="block text-sm font-medium text-campfire-light mb-1">
|
||||
URL фона
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="backdrop_url"
|
||||
value={formData.backdrop_url}
|
||||
onChange={handleChange}
|
||||
className="input w-full"
|
||||
/>
|
||||
</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"
|
||||
name="is_published"
|
||||
checked={formData.is_published}
|
||||
onChange={handleChange}
|
||||
className="mr-2"
|
||||
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 className="text-sm font-medium">Опубликовать сразу</label>
|
||||
<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>
|
||||
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
className={`btn-primary ${loading ? 'opacity-50 cursor-not-allowed' : ''} transition-colors duration-200`}
|
||||
>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
{loading ? (isEditing ? 'Сохранение...' : 'Создание...') : (isEditing ? 'Сохранить изменения' : 'Создать контент')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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;
|
@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FaDiscord, FaTelegramPlane, FaFire } from "react-icons/fa";
|
||||
import {
|
||||
RiOpenaiFill,
|
||||
@ -5,40 +6,63 @@ import {
|
||||
RiStackOverflowLine,
|
||||
} from "react-icons/ri";
|
||||
import { Link } from "react-router-dom";
|
||||
import Logo from "../ui/Logo";
|
||||
|
||||
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-charcoal py-12 mt-20">
|
||||
<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">
|
||||
<Logo size="small" />
|
||||
<span className="ml-2 text-xl font-bold">CampFire Critics</span>
|
||||
{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-ash">
|
||||
<p className="mt-4 text-campfire-light/60">
|
||||
Делаем хорошо, но на отъебись.
|
||||
</p>
|
||||
<div className="flex mt-6 space-x-4">
|
||||
<a
|
||||
href="#"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
<FaDiscord size={20} />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
<FaTelegramPlane size={20} />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
<FaFire size={20} />
|
||||
</a>
|
||||
@ -47,85 +71,93 @@ function Footer() {
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="text-lg font-semibold mb-4">Атлас</h3>
|
||||
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Атлас</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
Главная
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/discover/movies"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
to="/catalog?type=movie"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
Фильмы
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/discover/tv"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
to="/catalog?type=tv"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
Сериалы
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/discover/games"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
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">Правовая информация</h3>
|
||||
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Правовая информация</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
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>
|
||||
<li>
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
>
|
||||
Конфиденциальность
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/cookies"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
>
|
||||
Куки
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="text-lg font-semibold mb-4">Контакты</h3>
|
||||
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Контакты</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-campfire-ash">
|
||||
<li className="text-campfire-light/60">
|
||||
<span>general@campfiregg.ru</span>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
to="/support"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
Связаться с нами
|
||||
</Link>
|
||||
@ -133,7 +165,7 @@ function Footer() {
|
||||
<li>
|
||||
<Link
|
||||
to="/faq"
|
||||
className="text-campfire-ash hover:text-campfire-amber"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
|
||||
>
|
||||
FAQ
|
||||
</Link>
|
||||
@ -142,14 +174,14 @@ function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-campfire-dark mt-12 pt-8 text-center text-campfire-ash">
|
||||
<div className="border-t border-campfire-dark mt-12 pt-8 text-center text-campfire-light/60">
|
||||
<p>
|
||||
© {currentYear} CampFire Critics. Почти все права защищены.
|
||||
© {currentYear} CampFire мнеие. Никакие права не защищены.
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
<a
|
||||
href="#"
|
||||
className="text-campfire-ash hover:text-campfire-amber inline-flex items-center"
|
||||
className="text-campfire-light/60 hover:text-campfire-primary transition-colors inline-flex items-center"
|
||||
>
|
||||
VibeCoded with
|
||||
<RiStackOverflowLine className="ml-1" size={20} />
|
||||
|
@ -1,209 +1,389 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { FiSearch, FiMenu, FiX, FiUser } from "react-icons/fi";
|
||||
import SearchBar from "../ui/SearchBar";
|
||||
import Logo from "../ui/Logo";
|
||||
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 [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
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
|
||||
|
||||
// Close mobile menu when route changes
|
||||
// Эффект для проверки доступности локального логотипа при монтировании
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
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);
|
||||
}, [location.pathname]);
|
||||
};
|
||||
|
||||
// Handle scroll effect for header
|
||||
// Close menus/search when clicking outside
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (window.scrollY > 10) {
|
||||
setIsScrolled(true);
|
||||
} else {
|
||||
setIsScrolled(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate("/");
|
||||
} catch (error) {
|
||||
console.error("Не удалось выйти:", error);
|
||||
// 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.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled ? "bg-campfire-dark shadow-lg py-2" : "bg-transparent py-4"
|
||||
}`}
|
||||
>
|
||||
<div className="container-custom">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center">
|
||||
<Logo />
|
||||
<span className="ml-2 text-xl font-bold hidden sm:block">
|
||||
CampFire Critics
|
||||
</span>
|
||||
</Link>
|
||||
const canAccessAdmin = canManageSystem(user);
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors"
|
||||
>
|
||||
Главная
|
||||
</Link>
|
||||
<Link
|
||||
to="/discover/movies"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors"
|
||||
>
|
||||
Фильмы
|
||||
</Link>
|
||||
<Link
|
||||
to="/discover/tv"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors"
|
||||
>
|
||||
Сериалы
|
||||
</Link>
|
||||
<Link
|
||||
to="/discover/games"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors"
|
||||
>
|
||||
Игры
|
||||
</Link>
|
||||
</nav>
|
||||
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' : ''
|
||||
}
|
||||
];
|
||||
|
||||
{/* Search and User Actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
className="p-2 text-campfire-light hover:text-campfire-amber"
|
||||
aria-label="Найти"
|
||||
>
|
||||
<FiSearch size={20} />
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="relative group">
|
||||
<button className="flex items-center space-x-2 p-2 rounded-full bg-campfire-charcoal">
|
||||
{userProfile?.profilePicture ? (
|
||||
const profileItems = [
|
||||
{
|
||||
icon: (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<img
|
||||
src={userProfile.profilePicture}
|
||||
alt={userProfile.username}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<FiUser size={20} className="text-campfire-light" />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute right-0 mt-2 w-48 py-2 bg-campfire-charcoal rounded-md shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300">
|
||||
{userProfile?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin/media"
|
||||
className="block px-4 py-2 hover:bg-campfire-dark"
|
||||
>
|
||||
Админ панель
|
||||
</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}`}
|
||||
className="block px-4 py-2 hover:bg-campfire-dark"
|
||||
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>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-4 py-2 hover:bg-campfire-dark"
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{user && (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors"
|
||||
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>
|
||||
<Link
|
||||
to="/register"
|
||||
className="btn-primary"
|
||||
<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"
|
||||
>
|
||||
Регистрация
|
||||
</Link>
|
||||
<FaSignOutAlt />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-2 text-campfire-light"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label="Меню"
|
||||
>
|
||||
{isMenuOpen ? <FiX size={24} /> : <FiMenu size={24} />}
|
||||
<div className="md:hidden">
|
||||
<button onClick={toggleMobileMenu} className="text-campfire-light focus:outline-none">
|
||||
{isMobileMenuOpen ? <FaTimes size={24} /> : <FaBars size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar (expandable) */}
|
||||
<div
|
||||
className={`mt-4 transition-all duration-300 overflow-hidden ${
|
||||
isSearchOpen ? "max-h-20 opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<SearchBar onClose={() => setIsSearchOpen(false)} />
|
||||
</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 */}
|
||||
<div
|
||||
className={`md:hidden transition-all duration-300 overflow-hidden ${
|
||||
isMenuOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
<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"
|
||||
>
|
||||
<nav className="mt-4 flex flex-col space-y-3 pb-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
|
||||
<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`
|
||||
}
|
||||
>
|
||||
Главная
|
||||
</Link>
|
||||
<Link
|
||||
to="/discover/movies"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors py-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`
|
||||
}
|
||||
>
|
||||
Фильмы
|
||||
</Link>
|
||||
<Link
|
||||
to="/discover/series"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors py-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"
|
||||
>
|
||||
Сериалы
|
||||
</Link>
|
||||
<Link
|
||||
to="/discover/games"
|
||||
className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
|
||||
>
|
||||
Игры
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
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;
|
@ -1,32 +1,84 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaStar } from 'react-icons/fa';
|
||||
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]);
|
||||
|
||||
function MediaCard({ media }) {
|
||||
const { id, title, poster, rating, releaseDate, type } = media;
|
||||
|
||||
return (
|
||||
<Link to={`/media/${id}`} className="block">
|
||||
<div className="card group h-full">
|
||||
<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={poster}
|
||||
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">
|
||||
<FaStar className="text-campfire-amber mr-1" size={14} />
|
||||
<span className="text-sm font-medium">
|
||||
{rating ? (rating / 2).toFixed(1) : 'N/A'}
|
||||
<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">
|
||||
<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">
|
||||
{new Date(releaseDate).getFullYear()}
|
||||
{releaseDate ? new Date(releaseDate).getFullYear() : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,76 +1,30 @@
|
||||
import { useRef } from 'react';
|
||||
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import MediaCard from './MediaCard';
|
||||
import React from 'react';
|
||||
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
|
||||
|
||||
function MediaCarousel({ title, media = [], mediaType = 'movie', seeAllLink }) {
|
||||
const carouselRef = useRef(null);
|
||||
|
||||
const scroll = (direction) => {
|
||||
if (carouselRef.current) {
|
||||
const { current } = carouselRef;
|
||||
const scrollAmount = direction === 'left'
|
||||
? -current.offsetWidth * 0.8
|
||||
: current.offsetWidth * 0.8;
|
||||
|
||||
current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!media.length) {
|
||||
return null;
|
||||
// Simple Media Carousel component
|
||||
const MediaCarousel = ({ media, userProfile }) => {
|
||||
if (!media || media.length === 0) {
|
||||
return <div className="text-campfire-ash text-center">Нет контента для отображения.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold">{title}</h2>
|
||||
{seeAllLink && (
|
||||
<a href={seeAllLink} className="text-campfire-amber hover:text-campfire-ember">
|
||||
See All
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Left Navigation Arrow */}
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-campfire-dark bg-opacity-50 hover:bg-opacity-75 p-2 rounded-full text-campfire-light transition-all transform -translate-x-1/2 opacity-0 group-hover:opacity-100 group-hover:translate-x-0 duration-300"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<FiChevronLeft size={24} />
|
||||
</button>
|
||||
|
||||
{/* Carousel Container */}
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex overflow-x-auto pb-4 -mx-4 px-4 scrollbar-hide group"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{media.map((item) => (
|
||||
<div key={item.id} className="min-w-[180px] md:min-w-[220px] px-2 flex-shrink-0">
|
||||
<MediaCard media={item} type={mediaType} />
|
||||
// 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>
|
||||
|
||||
{/* Right Navigation Arrow */}
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-campfire-dark bg-opacity-50 hover:bg-opacity-75 p-2 rounded-full text-campfire-light transition-all transform translate-x-1/2 opacity-0 group-hover:opacity-100 group-hover:translate-x-0 duration-300"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<FiChevronRight size={24} />
|
||||
</button>
|
||||
</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;
|
@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
RadialLinearScale,
|
||||
@ -5,11 +6,10 @@ import {
|
||||
LineElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Radar } from 'react-chartjs-2';
|
||||
|
||||
// Register ChartJS components
|
||||
ChartJS.register(
|
||||
RadialLinearScale,
|
||||
PointElement,
|
||||
@ -19,111 +19,120 @@ ChartJS.register(
|
||||
Legend
|
||||
);
|
||||
|
||||
function RatingChart({ ratings, size = 'medium', showLegend = false }) {
|
||||
// Default ratings if none provided
|
||||
const defaultRatings = {
|
||||
story: 0,
|
||||
visuals: 0,
|
||||
performance: 0,
|
||||
soundtrack: 0,
|
||||
enjoyment: 0
|
||||
// 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
|
||||
};
|
||||
|
||||
// Merge provided ratings with defaults
|
||||
const mergedRatings = { ...defaultRatings, ...ratings };
|
||||
|
||||
// Chart data
|
||||
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: ['Story', 'Visuals', 'Performance', 'Soundtrack', 'Enjoyment'],
|
||||
labels: chartLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Rating',
|
||||
data: [
|
||||
mergedRatings.story,
|
||||
mergedRatings.visuals,
|
||||
mergedRatings.performance,
|
||||
mergedRatings.soundtrack,
|
||||
mergedRatings.enjoyment
|
||||
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,
|
||||
},
|
||||
],
|
||||
backgroundColor: 'rgba(255, 157, 0, 0.2)',
|
||||
borderColor: 'rgba(255, 157, 0, 1)',
|
||||
borderWidth: 1,
|
||||
pointBackgroundColor: 'rgba(255, 157, 0, 1)',
|
||||
pointBorderColor: '#fff',
|
||||
pointHoverBackgroundColor: '#fff',
|
||||
pointHoverBorderColor: 'rgba(255, 69, 0, 1)',
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Chart options
|
||||
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: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 10,
|
||||
ticks: {
|
||||
stepSize: 2,
|
||||
backdropColor: 'transparent',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
color: chartColors.ash + '40', // Ash with transparency
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
color: chartColors.ash + '40', // Ash with transparency
|
||||
},
|
||||
pointLabels: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
color: chartColors.light, // Light text for labels
|
||||
font: {
|
||||
size: size === 'small' ? 8 : 12,
|
||||
},
|
||||
},
|
||||
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: showLegend,
|
||||
display: false, // Hide legend
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(45, 55, 72, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
bodyFont: {
|
||||
size: 14,
|
||||
},
|
||||
titleFont: {
|
||||
size: 16,
|
||||
weight: 'bold',
|
||||
},
|
||||
padding: 10,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `Rating: ${context.raw}/10`;
|
||||
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.2
|
||||
tension: 0.1 // Add some tension for smoother lines
|
||||
}
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
};
|
||||
|
||||
// Size classes
|
||||
const sizeClasses = {
|
||||
small: 'w-32 h-32',
|
||||
medium: 'w-64 h-64',
|
||||
large: 'w-96 h-96'
|
||||
};
|
||||
// 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={`${sizeClasses[size]} mx-auto`}>
|
||||
<div className={`relative mx-auto ${containerClasses}`}>
|
||||
<Radar data={data} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
@ -3,22 +3,26 @@ import { Link } from 'react-router-dom';
|
||||
import { FaThumbsUp, FaComment, FaShare } from 'react-icons/fa';
|
||||
import RatingChart from './RatingChart';
|
||||
|
||||
function ReviewCard({ review, isDetailed = false }) {
|
||||
// Accept characteristics prop
|
||||
function ReviewCard({ review, isDetailed = false, characteristics }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const {
|
||||
id,
|
||||
user,
|
||||
users, // Changed from user to users
|
||||
content,
|
||||
ratings,
|
||||
likes,
|
||||
comments,
|
||||
createdAt,
|
||||
created_at,
|
||||
spoiler
|
||||
} = review;
|
||||
|
||||
// Use users object and rename for clarity
|
||||
const userProfile = users;
|
||||
|
||||
// Format date
|
||||
const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
|
||||
const formattedDate = new Date(created_at).toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@ -36,18 +40,18 @@ function ReviewCard({ review, isDetailed = false }) {
|
||||
{/* Review Header */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center">
|
||||
<Link to={`/profile/${user.id}`}>
|
||||
<Link to={`/profile/${userProfile?.username}`}> {/* Use userProfile?.id */}
|
||||
<img
|
||||
src={user.profilePicture || 'https://via.placeholder.com/40'}
|
||||
alt={user.username}
|
||||
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/${user.id}`} className="font-medium text-campfire-light hover:text-campfire-amber">
|
||||
{user.username}
|
||||
<Link to={`/profile/${userProfile?.username}`} className="font-medium text-campfire-light hover:text-campfire-amber"> {/* Use userProfile?.id */}
|
||||
{userProfile?.username} {/* Use userProfile?.username */}
|
||||
</Link>
|
||||
{user.isCritic && (
|
||||
{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>
|
||||
@ -63,8 +67,13 @@ function ReviewCard({ review, isDetailed = false }) {
|
||||
</div>
|
||||
|
||||
{/* Review Content */}
|
||||
<div className={`${isDetailed ? 'grid grid-cols-1 md:grid-cols-3 gap-8' : ''}`}>
|
||||
<div className={`${isDetailed ? 'md:col-span-2' : ''}`}>
|
||||
{/* 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">
|
||||
@ -89,11 +98,16 @@ function ReviewCard({ review, isDetailed = false }) {
|
||||
</div>
|
||||
|
||||
{/* Rating Chart */}
|
||||
<div className={`${isDetailed ? 'md:col-span-1' : 'hidden md:block'}`}>
|
||||
{/* 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}
|
||||
size={isDetailed ? 'medium' : 'small'}
|
||||
// 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>
|
||||
@ -106,8 +120,9 @@ function ReviewCard({ review, isDetailed = false }) {
|
||||
<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}</span>
|
||||
<span>{comments?.length || 0}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className="flex items-center text-campfire-ash hover:text-campfire-amber">
|
||||
|
@ -1,120 +1,465 @@
|
||||
import { useState } from 'react';
|
||||
import { FaStar } from 'react-icons/fa';
|
||||
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)
|
||||
|
||||
function ReviewForm({ mediaId, mediaType, onSubmit }) {
|
||||
const [ratings, setRatings] = useState({
|
||||
story: 5,
|
||||
visuals: 5,
|
||||
performance: 5,
|
||||
soundtrack: 5,
|
||||
enjoyment: 5
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
const labels = {
|
||||
story: 'Story & Writing',
|
||||
visuals: 'Visuals & Effects',
|
||||
performance: 'Acting/Performance',
|
||||
soundtrack: 'Sound & Music',
|
||||
enjoyment: 'Overall Enjoyment'
|
||||
// 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
|
||||
[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 {
|
||||
// Calculate overall rating
|
||||
const overallRating = Object.values(ratings).reduce((sum, rating) => sum + rating, 0) / Object.keys(ratings).length;
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
// Prepare review data
|
||||
const reviewData = {
|
||||
mediaId,
|
||||
mediaType,
|
||||
content,
|
||||
ratings,
|
||||
overallRating,
|
||||
hasSpoilers,
|
||||
createdAt: new Date().toISOString()
|
||||
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
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setContent('');
|
||||
setRatings({
|
||||
story: 5,
|
||||
visuals: 5,
|
||||
performance: 5,
|
||||
soundtrack: 5,
|
||||
enjoyment: 5
|
||||
});
|
||||
setHasSpoilers(false);
|
||||
} 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">
|
||||
<h2 className="text-xl font-bold mb-6">Write Your Review</h2>
|
||||
<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">
|
||||
{/* Rating Sliders */}
|
||||
<div className="space-y-6 mb-6">
|
||||
{Object.keys(ratings).map(category => (
|
||||
<div key={category} className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<label className="text-campfire-light">{labels[category]}</label>
|
||||
<span className="flex items-center text-campfire-amber">
|
||||
<FaStar className="mr-1" />
|
||||
{ratings[category]}
|
||||
|
||||
{/* 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>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={ratings[category]}
|
||||
onChange={(e) => handleRatingChange(category, parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-campfire-dark rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #FF9D00 0%, #FF9D00 ${(ratings[category] - 1) * 11.1}%, #2D3748 ${(ratings[category] - 1) * 11.1}%, #2D3748 100%)`
|
||||
}}
|
||||
{/* Flame Rating Input Component */}
|
||||
<FlameRatingInput
|
||||
value={ratings[key] !== undefined ? ratings[key] : 5}
|
||||
onChange={(value) => handleRatingChange(key, value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Review Text */}
|
||||
{/* Review Text - Using ReactQuill */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="review-content" className="block mb-2 text-campfire-light">
|
||||
Your Review
|
||||
<label htmlFor="review-content" className="block mb-2 text-campfire-light text-sm font-medium">
|
||||
Ваша рецензия <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="review-content"
|
||||
rows="8"
|
||||
placeholder="Share your thoughts on this title..."
|
||||
<ReactQuill
|
||||
theme="snow" // Use the snow theme
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full p-3 bg-campfire-dark border border-campfire-ash rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
|
||||
required
|
||||
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 */}
|
||||
@ -124,31 +469,51 @@ function ReviewForm({ mediaId, mediaType, onSubmit }) {
|
||||
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"
|
||||
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">
|
||||
This review contains spoilers
|
||||
<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">Your Rating Preview</p>
|
||||
<RatingChart ratings={ratings} size="medium" />
|
||||
<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">
|
||||
<div className="flex justify-end mt-6"> {/* Added mt-6 for spacing */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn-primary"
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Review'}
|
||||
{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>
|
||||
);
|
||||
|
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;
|
@ -1,15 +1,20 @@
|
||||
import { FaFire } from 'react-icons/fa';
|
||||
import React from 'react';
|
||||
|
||||
function Logo({ size = "default" }) {
|
||||
function Logo({ size = "large" }) {
|
||||
const sizeClasses = {
|
||||
small: "w-6 h-6",
|
||||
default: "w-8 h-8",
|
||||
large: "w-12 h-12"
|
||||
large: "w-16 h-16"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative rounded-full flex items-center justify-center ${sizeClasses[size]}`}>
|
||||
<FaFire className="text-campfire-amber animate-flicker" size={size === "small" ? 16 : size === "large" ? 32 : 24} />
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
@ -1,119 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { FiSearch, FiX } from 'react-icons/fi';
|
||||
import { useMedia } from '../../contexts/MediaContext';
|
||||
import { searchMedia } from '../../services/pocketbaseService';
|
||||
import SearchResults from './SearchResults';
|
||||
|
||||
function SearchBar({ onClose }) {
|
||||
const SearchBar = ({ onClose }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const { handleSearch, searchResults, loading } = useMedia();
|
||||
const navigate = useNavigate();
|
||||
const [results, setResults] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
if (onClose) onClose();
|
||||
// Фокус на инпут при монтировании
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
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);
|
||||
|
||||
// Debounce search as user types
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (value.trim().length > 2) {
|
||||
handleSearch(value);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
const handleResultClick = (id, mediaType) => {
|
||||
navigate(`/media/${id}?type=${mediaType}`);
|
||||
if (onClose) onClose();
|
||||
const handleResultClick = () => {
|
||||
onClose(); // Закрываем поиск при клике на результат
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<form onSubmit={handleSubmit} 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={handleChange}
|
||||
placeholder="Search for movies, TV shows, games..."
|
||||
className="w-full bg-campfire-dark border border-campfire-ash rounded-full px-5 py-3 pl-12 text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent outline-none"
|
||||
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"
|
||||
/>
|
||||
<FiSearch className="absolute left-4 top-1/2 transform -translate-y-1/2 text-campfire-ash" size={18} />
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-16 top-1/2 transform -translate-y-1/2 text-campfire-ash hover:text-campfire-light"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-campfire-ash hover:text-campfire-light"
|
||||
>
|
||||
<FiX size={18} />
|
||||
<FiX />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 bg-campfire-amber text-campfire-dark rounded-full px-4 py-1 text-sm font-medium hover:bg-campfire-ember transition-colors"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-campfire-light hover:text-campfire-amber transition-colors"
|
||||
>
|
||||
Search
|
||||
Отмена
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Quick results dropdown */}
|
||||
{query.trim().length > 2 && searchResults.length > 0 && (
|
||||
<div className="absolute z-10 mt-2 w-full bg-campfire-charcoal rounded-md shadow-lg max-h-96 overflow-y-auto">
|
||||
<div className="py-2">
|
||||
{loading ? (
|
||||
<div className="px-4 py-2 text-campfire-ash">Loading...</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.slice(0, 5).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleResultClick(item.id, item.media_type)}
|
||||
className="px-4 py-2 hover:bg-campfire-dark cursor-pointer flex items-center"
|
||||
>
|
||||
{item.poster_path ? (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w92${item.poster_path}`}
|
||||
alt={item.title || item.name}
|
||||
className="w-10 h-14 object-cover rounded mr-3"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-14 bg-campfire-dark rounded mr-3 flex items-center justify-center text-campfire-ash">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-campfire-light">
|
||||
{item.title || item.name}
|
||||
</div>
|
||||
<div className="text-sm text-campfire-ash">
|
||||
{item.media_type === 'movie' ? 'Movie' : 'TV Show'} • {item.release_date?.substring(0, 4) || item.first_air_date?.substring(0, 4) || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{searchResults.length > 5 && (
|
||||
<div
|
||||
className="px-4 py-2 text-center text-campfire-amber cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
>
|
||||
See all results
|
||||
</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;
|
@ -3,21 +3,26 @@ import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import {
|
||||
supabase,
|
||||
signUp as supabaseSignUp,
|
||||
signIn as supabaseSignIn,
|
||||
signOut as supabaseSignOut,
|
||||
getCurrentUser,
|
||||
pb, // Import PocketBase instance for authStore listener
|
||||
signUp as pbSignUp,
|
||||
signIn as pbSignIn,
|
||||
signOut as pbSignOut,
|
||||
getUserProfile,
|
||||
} from "../services/supabase";
|
||||
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;
|
||||
@ -26,203 +31,241 @@ export const useAuth = () => {
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [userProfile, setUserProfile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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: Нет userId для загрузки профиля');
|
||||
setLoading(false);
|
||||
return;
|
||||
console.log('AuthProvider: loadUserProfile: Нет userId для загрузки профиля');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('AuthProvider: Загрузка профиля пользователя:', userId);
|
||||
const profile = await getUserProfile(userId);
|
||||
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;
|
||||
|
||||
if (!profile) {
|
||||
console.log('AuthProvider: Профиль не найден, создаем новый');
|
||||
const { data: newProfile, error: createError } = await supabase
|
||||
.from('users')
|
||||
.insert([
|
||||
{
|
||||
id: userId,
|
||||
username: `user_${userId.slice(0, 8)}`,
|
||||
role: 'user'
|
||||
}
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) throw createError;
|
||||
console.log('AuthProvider: Новый профиль создан:', newProfile);
|
||||
setUserProfile(newProfile);
|
||||
} else {
|
||||
console.log('AuthProvider: Профиль загружен:', profile);
|
||||
setUserProfile(profile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AuthProvider: Ошибка загрузки профиля:', error);
|
||||
setError(error.message);
|
||||
setCurrentUser(null);
|
||||
setUserProfile(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка сессии при загрузке
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
console.log('AuthProvider: Проверка сессии...');
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||
console.log('AuthProvider: Результат проверки сессии:', { session, error: sessionError });
|
||||
|
||||
if (sessionError) {
|
||||
console.error('AuthProvider: Ошибка получения сессии:', sessionError);
|
||||
await supabase.auth.signOut();
|
||||
setCurrentUser(null);
|
||||
setUserProfile(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
console.log('AuthProvider: Пользователь найден в сессии');
|
||||
setCurrentUser(session.user);
|
||||
await loadUserProfile(session.user.id);
|
||||
} else {
|
||||
console.log('AuthProvider: Пользователь не найден в сессии');
|
||||
setCurrentUser(null);
|
||||
setUserProfile(null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AuthProvider: Ошибка проверки сессии:', error);
|
||||
setError(error.message);
|
||||
await supabase.auth.signOut();
|
||||
setCurrentUser(null);
|
||||
setUserProfile(null);
|
||||
setLoading(false);
|
||||
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: Инициализация...');
|
||||
console.log('AuthProvider: useEffect: Инициализация PocketBase authStore listener...'); // Add logging
|
||||
|
||||
const initialize = async () => {
|
||||
try {
|
||||
await checkSession();
|
||||
} catch (error) {
|
||||
console.error('AuthProvider: Ошибка инициализации:', error);
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (!mounted) return;
|
||||
|
||||
console.log('AuthProvider: Изменение состояния авторизации:', { event, session });
|
||||
|
||||
if (event === 'INITIAL_SESSION') {
|
||||
setLoading(false);
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
console.log('AuthProvider: Пользователь вошел в систему');
|
||||
setCurrentUser(session.user);
|
||||
await loadUserProfile(session.user.id);
|
||||
} else if (event === 'SIGNED_OUT' || event === 'USER_DELETED') {
|
||||
console.log('AuthProvider: Пользователь вышел из системы');
|
||||
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);
|
||||
setLoading(false);
|
||||
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: Отписка от изменений состояния авторизации');
|
||||
console.log('AuthProvider: useEffect cleanup: Отписка от изменений состояния авторизации'); // Add logging
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
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);
|
||||
setLoading(true); // Set loading at the start of the async operation
|
||||
setError(null);
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
if (error) throw error;
|
||||
return data;
|
||||
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 {
|
||||
setLoading(false);
|
||||
// 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) => {
|
||||
const signUp = async (email, password, username) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoading(true); // Set loading at the start of the async operation
|
||||
setError(null);
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password
|
||||
});
|
||||
if (error) throw error;
|
||||
return data;
|
||||
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 {
|
||||
setLoading(false);
|
||||
// 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);
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user: currentUser,
|
||||
userProfile,
|
||||
|
||||
// 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,
|
||||
signIn, // Include signIn in the context value
|
||||
signUp,
|
||||
signOut
|
||||
};
|
||||
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: Текущее состояние:', value);
|
||||
console.log('AuthProvider: Rendering with value:', { user: !!value.user, userProfile: !!value.userProfile, loading: value.loading, error: !!value.error, isInitialized: value.isInitialized }); // Add logging
|
||||
|
||||
if (loading && !currentUser) {
|
||||
console.log('AuthProvider: Отображение состояния загрузки');
|
||||
// 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="text-campfire-amber">Загрузка...</div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
@ -1,6 +1,5 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { searchMedia, getMediaDetails, validateMediaData, formatMediaData } from '../services/mediaService';
|
||||
import { createMedia } from '../services/supabase';
|
||||
import { createMedia, validateMediaData, formatMediaData } from '../services/pocketbaseService'; // Import from pocketbaseService, including validation/formatting
|
||||
|
||||
const MediaContext = createContext();
|
||||
|
||||
@ -13,72 +12,60 @@ export const useMedia = () => {
|
||||
};
|
||||
|
||||
export const MediaProvider = ({ children }) => {
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [selectedMedia, setSelectedMedia] = useState(null);
|
||||
// Removed searchResults and selectedMedia states as search/details logic is likely handled elsewhere now
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSearch = async (query, type = 'movie') => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const results = await searchMedia(query, type);
|
||||
setSearchResults(results);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
setError('Ошибка при поиске медиа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectMedia = async (tmdbId, type) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const details = await getMediaDetails(tmdbId, type);
|
||||
setSelectedMedia(details);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media details:', error);
|
||||
setError('Ошибка при получении деталей медиа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// 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) {
|
||||
throw new Error(errors.join('\n'));
|
||||
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('Error creating media:', 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;
|
||||
}
|
||||
throw error; // Re-throw to allow calling component to handle
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
searchResults,
|
||||
selectedMedia,
|
||||
// Removed searchResults, selectedMedia
|
||||
loading,
|
||||
error,
|
||||
handleSearch,
|
||||
handleSelectMedia,
|
||||
// Removed handleSearch, handleSelectMedia
|
||||
handleCreateMedia
|
||||
};
|
||||
|
||||
|
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;
|
||||
};
|
264
src/index.css
264
src/index.css
@ -2,91 +2,271 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-primary: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
--color-bg: #1A202C;
|
||||
--color-text: rgba(255, 255, 255, 0.87);
|
||||
--color-accent: #FF9D00;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-campfire-dark text-campfire-light font-sans m-0 min-h-screen;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@apply bg-campfire-dark text-campfire-light;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-bold leading-tight mb-4;
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl md:text-4xl;
|
||||
/* Custom scrollbar styles */
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl md:text-3xl;
|
||||
.overflow-hidden {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl md:text-2xl;
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
body {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-campfire-amber transition-colors duration-300;
|
||||
/* Keep custom thin scrollbar for specific elements if needed */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--campfire-amber) var(--campfire-charcoal);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@apply text-campfire-ember;
|
||||
.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 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
@apply container mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-md font-medium transition-all duration-300 focus:outline-none;
|
||||
@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 btn bg-campfire-amber text-campfire-dark hover:bg-campfire-ember;
|
||||
@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 btn border border-campfire-ash text-campfire-ash hover:bg-campfire-charcoal;
|
||||
@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;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-campfire-charcoal rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:scale-[1.02];
|
||||
.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 bg-campfire-dark border border-campfire-ash rounded-md px-4 py-2 text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent outline-none transition duration-300;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
.label {
|
||||
@apply block text-sm font-medium text-campfire-light/60 mb-1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-campfire-dark;
|
||||
.card {
|
||||
@apply bg-campfire-darker rounded-lg shadow-lg border border-campfire-dark/20 overflow-hidden;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-campfire-ash rounded-full;
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-campfire-dark/20;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-campfire-amber;
|
||||
.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;
|
||||
}
|
||||
}
|
10
src/main.jsx
10
src/main.jsx
@ -1,10 +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: Приложение отрендерено');
|
||||
|
@ -1,150 +1,322 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { supabase } from '../services/supabase';
|
||||
import MediaForm from '../components/admin/MediaForm';
|
||||
import Modal from '../components/Modal';
|
||||
import MediaForm from '../components/admin/MediaForm'; // Import MediaForm
|
||||
|
||||
const AdminMediaPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, userProfile, loading: authLoading } = useAuth();
|
||||
const [media, setMedia] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
console.log('AdminMediaPage mounted, user:', user);
|
||||
const { userProfile } = useAuth();
|
||||
const isAdminOrCritic = userProfile && (userProfile.role === 'admin' || userProfile.is_critic === true);
|
||||
|
||||
if (!authLoading && !user) {
|
||||
console.log('No user, redirecting to login');
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
// 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: 'Рецензии (возр.)' },
|
||||
], []);
|
||||
|
||||
if (userProfile?.role !== 'admin') {
|
||||
console.log('Access denied');
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
loadMedia();
|
||||
}, [user, userProfile, authLoading, navigate]);
|
||||
|
||||
const loadMedia = async () => {
|
||||
try {
|
||||
const fetchMedia = async (currentPage, type, published, sort) => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('media')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setMedia(data || []);
|
||||
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 loading media:', err);
|
||||
setError(err.message);
|
||||
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('Вы уверены, что хотите удалить этот медиа-контент?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm("Вы уверены, что хотите удалить этот контент?")) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('media')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setMedia(media.filter(item => item.id !== id));
|
||||
await deleteMedia(id);
|
||||
// Refresh the list after deletion
|
||||
fetchMedia(page, filterType, filterPublished, sortField);
|
||||
} catch (err) {
|
||||
console.error('Error deleting media:', err);
|
||||
setError(err.message);
|
||||
console.error("Error deleting media:", err);
|
||||
setError("Не удалось удалить контент.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="flex justify-center items-center h-screen">Загрузка...</div>;
|
||||
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 (!user || userProfile?.role !== 'admin') {
|
||||
return null;
|
||||
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="container-custom pt-20">
|
||||
<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-amber">Управление медиа</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
Добавить медиа
|
||||
<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>
|
||||
|
||||
{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">Загрузка...</div>
|
||||
) : media.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">
|
||||
{media.map((item) => (
|
||||
<div
|
||||
key={`${item.id}-${item.type}`}
|
||||
className="bg-campfire-charcoal rounded-lg overflow-hidden border border-campfire-ash/20"
|
||||
{/* 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"
|
||||
>
|
||||
{item.poster_path && (
|
||||
<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={item.poster_path}
|
||||
src={getFileUrl(item, 'poster', { thumb: '50x50' })} // Use getFileUrl
|
||||
alt={item.title}
|
||||
className="w-full h-48 object-cover"
|
||||
className="h-10 w-10 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="text-xl font-semibold text-campfire-amber mb-2">
|
||||
</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}
|
||||
</h3>
|
||||
<p className="text-campfire-light mb-4">
|
||||
{item.type === 'movie' ? 'Фильм' : 'Сериал'}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2">
|
||||
</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-red-500 hover:text-red-400"
|
||||
className="text-status-error hover:text-red-700 transition-colors"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-campfire-ash text-lg">
|
||||
Нет доступного контента.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<MediaForm
|
||||
onClose={() => setShowForm(false)}
|
||||
onSuccess={() => {
|
||||
setShowForm(false);
|
||||
loadMedia();
|
||||
}}
|
||||
/>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
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;
|
@ -1,131 +1,466 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMedia } from "../contexts/MediaContext";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { listMedia } from "../services/supabase";
|
||||
import { mediaTypes } from "../services/mediaService";
|
||||
import { FiTrendingUp, FiCalendar, FiAward } from "react-icons/fi";
|
||||
import MediaCarousel from "../components/media/MediaCarousel";
|
||||
import { getImageUrl } from "../services/tmdbApi";
|
||||
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 [media, setMedia] = useState([]);
|
||||
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 { user } = useAuth();
|
||||
const [stats, setStats] = useState({ mediaCount: 0, reviewsCount: 0 });
|
||||
const [posters, setPosters] = useState([]);
|
||||
const [featuredMedia, setFeaturedMedia] = useState([]);
|
||||
const [isDataReady, setIsDataReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMedia = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error } = await listMedia();
|
||||
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.5
|
||||
}
|
||||
|
||||
setMedia(data || []);
|
||||
} catch (err) {
|
||||
console.error("Error loading media:", err);
|
||||
setError("Не удалось загрузить контент");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadMedia();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-campfire-dark pt-20">
|
||||
<div className="container-custom py-12">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
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">
|
||||
<div className="container-custom py-12">
|
||||
<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>
|
||||
</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">
|
||||
Добро пожаловать в CampFire
|
||||
</h1>
|
||||
{user?.role === "admin" && (
|
||||
<Link to="/admin/media" className="btn-secondary">
|
||||
Управление контентом
|
||||
</Link>
|
||||
)}
|
||||
</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'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
{media.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{media.map((item) => (
|
||||
<Link
|
||||
key={`${item.id}-${item.type}`}
|
||||
to={`/media/${item.id}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="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">
|
||||
<div className="aspect-[2/3] relative">
|
||||
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={item.poster_url}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
src={getFileUrl(user, 'profile_picture', { thumb: '100x100' }) || '/default-avatar.png'}
|
||||
alt={user.username}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-campfire-charcoal/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-campfire-amber/20 text-campfire-amber">
|
||||
{item.type === "movie" ? "Фильм" : "Сериал"}
|
||||
</span>
|
||||
<span className="text-sm text-campfire-ash">
|
||||
{new Date(item.release_date).getFullYear()}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-campfire-light mb-1 group-hover:text-campfire-amber transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-campfire-ash line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-campfire-ash text-lg">
|
||||
Пока нет доступного контента
|
||||
</p>
|
||||
{user?.role === "admin" && (
|
||||
<Link to="/admin/media" className="inline-block mt-4 btn-primary">
|
||||
Добавить контент
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,92 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useAuth } from '../contexts/AuthContext'; // Import useAuth
|
||||
|
||||
const LoginPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const { signIn } = useAuth();
|
||||
|
||||
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();
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await signIn(email, password);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await signIn(login, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError(err.message || 'Ошибка входа');
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark">
|
||||
<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">
|
||||
Войдите, чтобы оценивать и рецензировать контент
|
||||
</p>
|
||||
</div>
|
||||
// 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="bg-status-error/20 text-status-error p-3 rounded-md mb-6 text-sm">
|
||||
<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-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-campfire-light mb-1"
|
||||
>
|
||||
Email
|
||||
<label htmlFor="login" className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="your@email.com"
|
||||
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="email"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-campfire-light"
|
||||
>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-campfire-light mb-1">
|
||||
Пароль
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs text-campfire-amber hover:text-campfire-ember transition-colors"
|
||||
>
|
||||
Забыли пароль?
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input w-full"
|
||||
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"
|
||||
@ -96,7 +105,7 @@ const LoginPage = () => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`btn-primary w-full ${loading ? "opacity-80" : ""}`}
|
||||
className={`btn-primary w-full ${loading ? "opacity-80 cursor-not-allowed" : ""} transition-colors duration-200`}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center">
|
||||
@ -120,7 +129,7 @@ const LoginPage = () => {
|
||||
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>
|
||||
) : (
|
||||
"Войти"
|
||||
@ -129,9 +138,9 @@ const LoginPage = () => {
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-campfire-ash">
|
||||
Нет аккаунта?{" "}
|
||||
Еще нет аккаунта?{" "}
|
||||
<Link
|
||||
to="/register"
|
||||
to="/auth/register"
|
||||
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors"
|
||||
>
|
||||
Зарегистрироваться
|
||||
@ -139,7 +148,6 @@ const LoginPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
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
|
@ -1,107 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getMediaById } from '../services/supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const MediaPage = () => {
|
||||
const { id } = useParams();
|
||||
const [media, setMedia] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const loadMedia = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getMediaById(id);
|
||||
setMedia(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading media:', err);
|
||||
setError('Не удалось загрузить информацию о медиа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (id) {
|
||||
loadMedia();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center">Загрузка...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
}
|
||||
|
||||
if (!media) {
|
||||
return <div className="text-center">Медиа не найдено</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
{/* Заголовок и основная информация */}
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">{media.title}</h1>
|
||||
<div className="flex flex-wrap gap-4 text-gray-600 mb-4">
|
||||
<span>Тип: {media.type}</span>
|
||||
{media.release_date && (
|
||||
<span>Дата выхода: {new Date(media.release_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
{media.rating && (
|
||||
<span>Рейтинг: {media.rating.toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
{media.description && (
|
||||
<p className="text-gray-700 mb-6">{media.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Постер и дополнительная информация */}
|
||||
{media.poster_url && (
|
||||
<div className="p-6 border-t">
|
||||
<img
|
||||
src={media.poster_url}
|
||||
alt={media.title}
|
||||
className="max-w-sm mx-auto rounded-lg shadow-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Рецензии */}
|
||||
<div className="p-6 border-t">
|
||||
<h2 className="text-2xl font-bold mb-4">Рецензии</h2>
|
||||
{media.reviews && media.reviews.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{media.reviews.map((review) => (
|
||||
<div key={review.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="font-semibold">{review.user.username}</span>
|
||||
{review.user.is_critic && (
|
||||
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
Критик
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-700">{review.content}</p>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{new Date(review.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Пока нет рецензий</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaPage;
|
@ -1,24 +1,40 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { FiHome } from "react-icons/fi";
|
||||
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';
|
||||
|
||||
function NotFoundPage() {
|
||||
const NotFoundPage = () => {
|
||||
return (
|
||||
<div className="pt-20 flex items-center justify-center min-h-screen">
|
||||
<div className="container-custom max-w-2xl text-center py-12">
|
||||
<h1 className="text-6xl font-bold mb-6 text-campfire-amber">404</h1>
|
||||
<h2 className="text-3xl font-bold mb-4">
|
||||
Оказавшись в лимбе, вы не нашли подходящую страницу
|
||||
</h2>
|
||||
<p className="text-campfire-ash mb-8">
|
||||
Похоже, вы сбились с пути. Страница, которую вы ищете, не существует
|
||||
или была перемещена.
|
||||
<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="btn-primary inline-flex items-center">
|
||||
<FiHome className="mr-2" /> Вернуться
|
||||
<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;
|
||||
|
File diff suppressed because it is too large
Load Diff
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;
|
@ -1,23 +1,43 @@
|
||||
import { useState } from "react";
|
||||
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 [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { signUp } = useAuth();
|
||||
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 (!email || !username || !password || !confirmPassword) {
|
||||
setError("Заполни все поля");
|
||||
if (!login || !password || !confirmPassword) {
|
||||
setError("Заполни обязательные поля");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -31,126 +51,191 @@ function RegisterPage() {
|
||||
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);
|
||||
await signUp(email, password, username);
|
||||
navigate("/");
|
||||
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) {
|
||||
setError(
|
||||
"Дружище, ты уже существуешь, либо кто-то другой зарегистрирован с такой же почтой."
|
||||
);
|
||||
console.error(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">
|
||||
<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">
|
||||
<h1 className="text-3xl font-bold mb-6 text-center">
|
||||
Присоединиться к CampFire Critics
|
||||
<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 mb-8 text-center">
|
||||
<p className="text-campfire-ash">
|
||||
Создай свою учетную запись CampFire, чтобы оценивать и рецензировать
|
||||
все что движется.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-md mb-6">
|
||||
<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}>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="email" className="block text-campfire-light mb-2">
|
||||
Email
|
||||
<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"
|
||||
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"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-campfire-light mb-2"
|
||||
>
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="Say. Your. Name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-campfire-light mb-2"
|
||||
>
|
||||
Пароль
|
||||
<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"
|
||||
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 className="mb-8">
|
||||
<label
|
||||
htmlFor="confirm-password"
|
||||
className="block text-campfire-light mb-2"
|
||||
>
|
||||
Пароль пароль
|
||||
<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"
|
||||
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"
|
||||
className={`btn-primary w-full ${loading ? "opacity-80 cursor-not-allowed" : ""} transition-colors duration-200`}
|
||||
>
|
||||
{loading ? "Запечатываем..." : "Создать"}
|
||||
{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">
|
||||
<span className="text-campfire-ash">Уже в строю?</span>{" "}
|
||||
<div className="mt-6 text-center text-sm text-campfire-ash">
|
||||
Уже в строю?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-campfire-amber hover:text-campfire-ember"
|
||||
to="/auth/login"
|
||||
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
|
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;
|
70
src/pages/legal/TermsOfServicePage.jsx
Normal file
70
src/pages/legal/TermsOfServicePage.jsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
const TermsOfServicePage = () => {
|
||||
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>Предоставлять точную и актуальную информацию</li>
|
||||
<li>Не передавать свои учетные данные третьим лицам</li>
|
||||
<li>Немедленно уведомлять нас о любом несанкционированном доступе</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Правила поведения</h2>
|
||||
<p>
|
||||
При использовании сервиса запрещается:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Нарушать законодательство</li>
|
||||
<li>Публиковать незаконный контент</li>
|
||||
<li>Спамить или рассылать нежелательные сообщения</li>
|
||||
<li>Нарушать права других пользователей</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Интеллектуальная собственность</h2>
|
||||
<p>
|
||||
Весь контент на сайте, включая тексты, изображения и дизайн, является собственностью
|
||||
CampFire или используется с разрешения правообладателей.
|
||||
</p>
|
||||
|
||||
<h2>5. Ограничение ответственности</h2>
|
||||
<p>
|
||||
CampFire не несет ответственности за:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Действия пользователей</li>
|
||||
<li>Потерю данных</li>
|
||||
<li>Временную недоступность сервиса</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Изменение условий</h2>
|
||||
<p>
|
||||
Мы оставляем за собой право изменять условия использования в любое время. Продолжение
|
||||
использования сервиса после внесения изменений означает принятие новых условий.
|
||||
</p>
|
||||
|
||||
<h2>7. Контактная информация</h2>
|
||||
<p>
|
||||
По всем вопросам, связанным с условиями использования, обращайтесь через форму
|
||||
обратной связи.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsOfServicePage;
|
82
src/pages/legal/UserAgreementPage.jsx
Normal file
82
src/pages/legal/UserAgreementPage.jsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
|
||||
const UserAgreementPage = () => {
|
||||
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>Предоставлять доступ к основному функционалу</li>
|
||||
<li>Обеспечивать техническую поддержку</li>
|
||||
<li>Защищать персональные данные пользователей</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Пользователь обязуется:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Соблюдать правила использования сервиса</li>
|
||||
<li>Не нарушать права других пользователей</li>
|
||||
<li>Предоставлять достоверную информацию</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Условия использования сервиса</h2>
|
||||
<p>
|
||||
Для использования сервиса необходимо:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Быть старше 13 лет</li>
|
||||
<li>Иметь доступ к интернету</li>
|
||||
<li>Зарегистрироваться на сайте</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Контент пользователей</h2>
|
||||
<p>
|
||||
Пользователи сохраняют права на созданный ими контент, но предоставляют Сервису
|
||||
неисключительную лицензию на его использование.
|
||||
</p>
|
||||
|
||||
<h2>5. Модерация контента</h2>
|
||||
<p>
|
||||
Сервис оставляет за собой право:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Удалять нарушающий правила контент</li>
|
||||
<li>Блокировать нарушителей</li>
|
||||
<li>Изменять правила модерации</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Ответственность сторон</h2>
|
||||
<p>
|
||||
Пользователь несет ответственность за:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Содержание публикуемого контента</li>
|
||||
<li>Безопасность своей учетной записи</li>
|
||||
<li>Соблюдение законодательства</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Заключительные положения</h2>
|
||||
<p>
|
||||
Настоящее соглашение вступает в силу с момента регистрации пользователя и действует
|
||||
до момента его удаления или изменения условий.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgreementPage;
|
@ -1 +0,0 @@
|
||||
// This file can be deleted as it's no longer needed
|
@ -1,208 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const IMDB_API_URL = 'https://imdb.iamidiotareyoutoo.com';
|
||||
const TMDB_API_KEY = import.meta.env.VITE_TMDB_API_KEY;
|
||||
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
|
||||
export const mediaTypes = {
|
||||
MOVIE: 'movie',
|
||||
TV: 'series',
|
||||
GAME: 'game'
|
||||
};
|
||||
|
||||
function isLocalStorageAvailable() {
|
||||
try {
|
||||
const testKey = '__test__';
|
||||
localStorage.setItem(testKey, testKey);
|
||||
localStorage.removeItem(testKey);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchIMDb(query, type = mediaTypes.ALL) {
|
||||
try {
|
||||
const response = await axios.get(`${IMDB_API_URL}/search`, {
|
||||
params: { q: query },
|
||||
timeout: 5000 // Добавлен таймаут
|
||||
});
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data
|
||||
.filter(item => {
|
||||
if (!item) return false;
|
||||
return type === mediaTypes.ALL ||
|
||||
(item.type && item.type.toLowerCase() === type);
|
||||
})
|
||||
.map(item => ({
|
||||
id: item.imdbID || '',
|
||||
title: item.title || 'Unknown',
|
||||
poster: item.poster || '',
|
||||
rating: Math.min(100, Math.max(0, parseFloat(item.rating || '0') * 10)),
|
||||
releaseDate: item.year || 'Unknown',
|
||||
type: (item.type || '').toLowerCase()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error searching IMDb:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrendingMedia(type) {
|
||||
try {
|
||||
if (!isLocalStorageAvailable()) return [];
|
||||
|
||||
const mediaStr = localStorage.getItem('media');
|
||||
if (!mediaStr) return [];
|
||||
|
||||
const media = JSON.parse(mediaStr);
|
||||
if (!Array.isArray(media)) return [];
|
||||
|
||||
return media
|
||||
.filter(item => item && item.type === type && item.trending === true)
|
||||
.slice(0, 10);
|
||||
} catch (error) {
|
||||
console.error('Error fetching trending media:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaById(id) {
|
||||
try {
|
||||
if (!isLocalStorageAvailable()) return null;
|
||||
|
||||
const mediaStr = localStorage.getItem('media');
|
||||
if (!mediaStr) return null;
|
||||
|
||||
const media = JSON.parse(mediaStr);
|
||||
if (!Array.isArray(media)) return null;
|
||||
|
||||
return media.find(item => item && item.id === id) || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const searchMedia = async (query, type = 'movie') => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${TMDB_BASE_URL}/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}&language=ru-RU`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch from TMDB');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.results.map(item => ({
|
||||
title: item.title || item.name,
|
||||
description: item.overview,
|
||||
poster_url: item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : null,
|
||||
release_date: item.release_date || item.first_air_date,
|
||||
type: type,
|
||||
tmdb_id: item.id,
|
||||
rating: item.vote_average
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error searching media:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMediaDetails = async (tmdbId, type = 'movie') => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${TMDB_BASE_URL}/${type}/${tmdbId}?api_key=${TMDB_API_KEY}&language=ru-RU`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch media details');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
title: data.title || data.name,
|
||||
description: data.overview,
|
||||
poster_url: data.poster_path ? `https://image.tmdb.org/t/p/w500${data.poster_path}` : null,
|
||||
release_date: data.release_date || data.first_air_date,
|
||||
type: type,
|
||||
tmdb_id: data.id,
|
||||
rating: data.vote_average,
|
||||
genres: data.genres.map(g => g.name).join(', '),
|
||||
runtime: data.runtime || data.episode_run_time?.[0] || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching media details:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export async function addMedia(mediaData) {
|
||||
try {
|
||||
if (!isLocalStorageAvailable()) {
|
||||
throw new Error('Local storage is not available');
|
||||
}
|
||||
|
||||
if (!mediaData || typeof mediaData !== 'object') {
|
||||
throw new Error('Invalid media data');
|
||||
}
|
||||
|
||||
const mediaStr = localStorage.getItem('media') || '[]';
|
||||
const media = JSON.parse(mediaStr);
|
||||
if (!Array.isArray(media)) {
|
||||
throw new Error('Invalid media data in storage');
|
||||
}
|
||||
|
||||
const newMedia = {
|
||||
...mediaData,
|
||||
id: Date.now().toString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
trending: Boolean(mediaData.trending),
|
||||
rating: Math.min(100, Math.max(0, Number(mediaData.rating) || 0)),
|
||||
ratingCount: Math.max(0, Number(mediaData.ratingCount) || 0)
|
||||
};
|
||||
|
||||
media.push(newMedia);
|
||||
localStorage.setItem('media', JSON.stringify(media));
|
||||
return newMedia.id;
|
||||
} catch (error) {
|
||||
console.error('Error adding media:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const validateMediaData = (data) => {
|
||||
const errors = [];
|
||||
|
||||
if (!data.title?.trim()) {
|
||||
errors.push('Название обязательно');
|
||||
}
|
||||
|
||||
if (!data.type || !Object.values(mediaTypes).includes(data.type)) {
|
||||
errors.push('Неверный тип медиа');
|
||||
}
|
||||
|
||||
if (data.rating && (isNaN(data.rating) || data.rating < 0 || data.rating > 10)) {
|
||||
errors.push('Рейтинг должен быть от 0 до 10');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const formatMediaData = (data) => {
|
||||
return {
|
||||
title: data.title?.trim(),
|
||||
description: data.description?.trim() || '',
|
||||
type: data.type,
|
||||
poster_url: data.poster_url?.trim() || null,
|
||||
release_date: data.release_date || null,
|
||||
rating: data.rating ? parseFloat(data.rating) : null,
|
||||
genres: data.genres?.trim() || '',
|
||||
runtime: data.runtime ? parseInt(data.runtime) : null
|
||||
};
|
||||
};
|
1669
src/services/pocketbaseService.js
Normal file
1669
src/services/pocketbaseService.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user