Compare commits

..

No commits in common. "d5899c137dea04132e96e415e9db53759f91512d" and "ef393743cbe7311794b60ff76d1cf98044492125" have entirely different histories.

113 changed files with 3758 additions and 16202 deletions

180
README.md
View File

@ -1,80 +1,50 @@
# CampFire Critics # CampFire Critics
CampFire Critics — это веб-приложение, созданное с использованием React, Vite и Tailwind CSS, предназначенное для каталогизации медиаконтента (фильмов, сериалов, игр, аниме) и управления пользовательскими отзывами. Приложение включает в себя систему аутентификации, подробные страницы медиа, профили пользователей, систему достижений, систему поддержки и административную панель для модерации контента и управления данными. В качестве бэкенда используется PocketBase. CampFire Critics — это веб-приложение, построенное на React, Vite и Tailwind CSS, предназначенное для управления медиаконтентом и пользовательскими отзывами. Приложение включает аутентификацию, управление медиа и административные функции для модерации контента.
## Функции ## Функции
* **Аутентификация пользователей**: Полная система регистрации, входа, выхода и сброса пароля с использованием PocketBase Auth. - **Аутентификация пользователей**: Вход, регистрация и управление профилем через Supabase.
* **Профили пользователей**: Просмотр профилей пользователей, отображение их статистики (количество рецензий, средний рейтинг, XP, уровень), списка достижений и витрины избранных рецензий. Возможность редактирования собственного профиля (описание, аватар, баннер, витрина). - **Управление медиа**: Просмотр, поиск и отображение медиаконтента с каруселью и карточным интерфейсом.
* **Каталог медиа**: Просмотр списка медиа с фильтрацией по типу (фильмы, сериалы, игры, аниме) и поиском. - **Система отзывов**: Подача и просмотр отзывов с рейтинговым графиком для визуализации обратной связи.
* **Страницы обзора медиа**: Подробные страницы для каждого медиа, включающие информацию о нем, список сезонов (для сериалов/аниме), а также раздел с пользовательскими рецензиями и рейтингами. - **Админская панель**: Управление медиа и отзывами через AdminMediaPage.
* **Система рецензий**: Пользователи могут оставлять рецензии на медиа или отдельные сезоны, выставлять оценки по нескольким характеристикам, указывать прогресс просмотра/прохождения и отмечать спойлеры. Рецензии отображаются на страницах медиа/сезонов и в профилях пользователей. - **Адаптивный дизайн**: Построен с использованием Tailwind CSS для адаптивной верстки на разных устройствах.
* **Система лайков рецензий**: Пользователи могут ставить лайки рецензиям других пользователей.
* **Система XP и уровней**: Пользователи получают XP за создание рецензий и получение достижений, что повышает их уровень. Уровень и XP отображаются в профиле.
* **Система достижений**: Пользователи могут получать достижения за различные действия. Список достижений отображается в профиле.
* **Система поддержки**: Пользователи могут создавать тикеты поддержки с выбором категории и описанием проблемы.
* **Административная панель**: Раздел для администраторов с возможностью управления медиа, пользователями, сезонами, достижениями и тикетами поддержки. Включает дашборд с общей статистикой.
* **Адаптивный дизайн**: Приложение адаптировано для корректного отображения на различных устройствах с использованием Tailwind CSS.
## Технологии ## Технологии
* **Фронтенд**: - **Фронтенд**: React, Vite, Tailwind CSS
* React: Библиотека для построения пользовательских интерфейсов. - **Бэкенд**: Supabase (Аутентификация, База данных)
* Vite: Быстрый сборщик фронтенда. - **Медиа API**: Интеграция с TMDB (The Movie Database) для получения данных о медиа
* Tailwind CSS: Утилитарный CSS-фреймворк для быстрой стилизации. - **Управление состоянием**: React Context API (AuthContext, MediaContext)
* React Router DOM: Для маршрутизации в приложении.
* `react-icons`: Набор популярных иконок.
* `chart.js` и `react-chartjs-2`: Для построения графиков рейтингов.
* `react-datepicker`: Для выбора дат.
* `react-fast-marquee`: Для бегущей строки (если используется).
* `react-quill`: WYSIWYG редактор для контента рецензий/описаний.
* `react-select`: Кастомизируемый компонент выбора.
* `react-toastify`: Для уведомлений (тостов).
* `dompurify`: Для очистки HTML контента.
* `uuid`: Для генерации уникальных ID.
* **Бэкенд**:
* PocketBase: Опенсорсный бэкенд в одном файле (Go) с базой данных SQLite, системой аутентификации, файловым хранилищем, realtime подписками и хуками.
* **Внешние API**:
* TMDB (The Movie Database): Используется для получения информации о фильмах, сериалах и, возможно, другом медиаконтенте (подразумевается, исходя из предыдущего README).
## Настройка проекта ## Настройка
1. **Клонирование репозитория** (стандартный шаг, не применимо в WebContainer): 1. **Клонируйте репозиторий**:
```bash ```bash
git clone https://github.com/your-username/campfire-critics.git git clone https://github.com/your-username/campfire-critics.git
cd campfire-critics cd campfire-critics
``` ```
2. **Установка зависимостей**: 2. **Установите зависимости**:
```bash ```bash
npm install npm install
``` ```
3. **Настройка PocketBase**: 3. **Установите Supabase CLI** (если не установлен):
* Скачайте и запустите PocketBase с [официального сайта](https://pocketbase.io/docs/getting-started/).
* Перейдите в Admin UI (обычно `http://127.0.0.1:8090/_/`). ```bash
* Создайте следующие коллекции (убедитесь, что включены необходимые поля и связи): npm install -g supabase
* `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. **Настройте переменные окружения**: 4. **Настройте переменные окружения**:
* Создайте файл `.env` в корне проекта, если его нет. - Переименуйте `.env.example` в `.env` и добавьте свои данные Supabase:
* Добавьте URL вашего PocketBase инстанса:
``` ```
VITE_POCKETBASE_URL=http://127.0.0.1:8090 VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
``` ```
Замените URL на адрес вашего запущенного PocketBase.
5. **Запустите сервер разработки**: 5. **Запустите сервер разработки**:
@ -82,105 +52,37 @@ CampFire Critics — это веб-приложение, созданное с
npm run dev npm run dev
``` ```
Приложение будет доступно по адресу, указанному Vite (обычно `http://localhost:5173`). 6. **Запустите миграции Supabase** (если необходимо):
```bash
npm run supabase:start
```
## Структура проекта и описание модулей ## Структура проекта
Проект организован следующим образом:
``` ```
src/ src/
├── App.jsx # Основной компонент приложения, определяет маршруты. ├── App.jsx # Основной компонент приложения
├── main.jsx # Точка входа в приложение, инициализирует React и оборачивает App в контекст-провайдеры и роутер. ├── main.jsx # Точка входа
├── index.css # Основной файл стилей, импортирует Tailwind CSS. ├── pages/ # Компоненты страниц (Главная, Вход, Админ и т.д.)
├── pages/ # Компоненты, представляющие целые страницы приложения. ├── components/ # Воспользуемые UI-компоненты
│ ├── HomePage.jsx # Главная страница с обзорами и статистикой. ├── contexts/ # Провайдеры React Context (Auth, Media)
│ ├── CatalogPage.jsx # Страница каталога медиа. ├── services/ # Утилиты API и сервисы (Supabase, TMDB)
│ ├── MediaOverviewPage.jsx# Страница с подробным обзором конкретного медиа. └── assets/ # Статические файлы
│ ├── 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 для обсуждения новой функции или исправления. 1. Откройте issue для обсуждения новой функции или исправления
2. Сделайте fork репозитория. 2. Сделайте fork репозитория
3. Создайте новую ветку (`git checkout -b feature-name`). 3. Создайте новую ветку (`git checkout -b feature-name`)
4. Зафиксируйте изменения. 4. Зафиксируйте изменения
5. Запушьте ветку (`git push origin feature-name`). 5. Запушите ветку (`git push origin feature-name`)
6. Создайте Pull Request. 6. Создайте Pull Request
## Требования ## Требования
* Node.js 18+ - Node.js 18+
* npm 8+ - npm 8+
* Запущенный инстанс PocketBase с настроенными коллекциями, RLS и хуками.
## Лицензия ## Лицензия

View File

@ -1,11 +1,10 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Updated favicon link --> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CampFire мнеие</title> <title>CampFire Critics Web Application</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,16 +0,0 @@
{
"$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"
}
}

2111
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +1,40 @@
{ {
"name": "campfirecritics", "name": "campfire-critics",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/forms": "^0.5.10", "@neondatabase/serverless": "^0.9.0",
"@tailwindcss/typography": "^0.5.16", "@supabase/supabase-js": "^2.39.3",
"chart.js": "^4.4.3", "axios": "^1.6.7",
"dompurify": "^3.2.5", "chart.js": "^4.4.1",
"framer-motion": "^11.18.2", "react": "^18.3.1",
"gsap": "^3.13.0", "react-bootstrap": "^2.10.9",
"pocketbase": "^0.21.3",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-datepicker": "^8.3.0", "react-dom": "^18.3.1",
"react-dom": "^18.2.0",
"react-fast-marquee": "^1.6.4",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-quill": "^2.0.0", "react-router-dom": "^6.22.2",
"react-router-dom": "^6.23.1", "supabase": "^2.22.12"
"react-select": "^5.10.1",
"react-toastify": "^10.0.5",
"uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.66", "@eslint/js": "^9.9.1",
"@types/react-dom": "^18.2.22", "@types/react": "^18.3.5",
"@vitejs/plugin-react": "^4.2.1", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19", "@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0", "autoprefixer": "^10.4.17",
"eslint-plugin-react": "^7.34.1", "eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"postcss": "^8.4.38", "eslint-plugin-react-refresh": "^0.4.11",
"tailwindcss": "^3.4.3", "globals": "^15.9.0",
"vite": "^5.2.0" "postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.4.2"
} }
} }

View File

@ -1,186 +0,0 @@
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

View File

@ -1 +0,0 @@

View File

@ -1 +0,0 @@

15
run-migration.ps1 Normal file
View File

@ -0,0 +1,15 @@
# Проверяем, установлен ли 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

9
setup-supabase.ps1 Normal file
View File

@ -0,0 +1,9 @@
# Установка 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

View File

@ -1,119 +1,39 @@
import React, { useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { Routes, Route } from 'react-router-dom'; import { AuthProvider } from "./contexts/AuthContext";
import { AuthProvider } from './contexts/AuthContext'; import { MediaProvider } from "./contexts/MediaContext";
import { ProfileActionsProvider } from './contexts/ProfileActionsContext'; import Header from "./components/layout/Header";
import { ClickSparkProvider, useClickSpark } from './contexts/ClickSparkContext'; import Footer from "./components/layout/Footer";
import ClickSpark from './components/reactbits/Animations/ClickSpark/ClickSpark'; import HomePage from "./pages/HomePage";
import AuthRoute from './components/auth/AuthRoute'; import MediaPage from "./pages/MediaPage";
import GuestRoute from './components/auth/GuestRoute'; import ProfilePage from "./pages/ProfilePage";
import Header from './components/layout/Header'; import LoginPage from "./pages/LoginPage";
import Footer from './components/layout/Footer'; import RegisterPage from "./pages/RegisterPage";
import HomePage from './pages/HomePage'; import NotFoundPage from "./pages/NotFoundPage";
import CatalogPage from './pages/CatalogPage'; import AdminMediaPage from "./pages/AdminMediaPage";
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 ( return (
<div className="flex flex-col min-h-screen bg-campfire-dark text-campfire-light"> <Router>
<ClickSpark <AuthProvider>
sparkColor="#FFA500" <MediaProvider>
sparkSize={10} <div className="flex flex-col min-h-screen">
sparkRadius={15}
sparkCount={8}
duration={400}
easing="cubic-bezier(0.4, 0, 0.2, 1)"
extraScale={1.0}
/>
<Header /> <Header />
<main className="flex-grow pt-16 pb-32"> <main className="flex-grow">
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/catalog" element={<CatalogPage />} /> <Route path="/media/:id" element={<MediaPage />} />
<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="/profile/:username" element={<ProfilePage />} />
<Route path="/settings" element={<AuthRoute><ProfileSettingsPage /></AuthRoute>} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/auth" element={<GuestRoute />}> <Route path="/admin/media" element={<AdminMediaPage />} />
<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 />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</main> </main>
<Footer /> <Footer />
</div> </div>
); </MediaProvider>
};
function App() {
return (
<AuthProvider>
<ProfileActionsProvider>
<ClickSparkProvider>
<AppContent />
</ClickSparkProvider>
</ProfileActionsProvider>
</AuthProvider> </AuthProvider>
</Router>
); );
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -1,713 +1,191 @@
import React, { useState, useEffect, useMemo } from 'react'; // Import useMemo import React, { useState } from 'react';
import { createMedia, updateMedia, validateMediaData, formatMediaData, getFileUrl, mediaTypes } from '../../services/pocketbaseService'; import { supabase } from '../../services/supabase';
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 [loading, setLoading] = useState(false);
const [errors, setErrors] = useState([]); const [error, setError] = useState(null);
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) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setErrors([]); setError(null);
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 { try {
if (isEditing) { const { data, error } = await supabase
await updateMedia(media.id, dataToSubmit); .from('media')
console.log('Media updated successfully'); .insert([{
} else { ...formData,
await createMedia(dataToSubmit); created_by: (await supabase.auth.getUser()).data.user.id
console.log('Media created successfully'); }])
} .select()
onSuccess(); // Close modal and refresh list .single();
} catch (err) {
console.error('Error submitting media form:', err); if (error) throw error;
if (err.response && err.response.data) {
console.error('PocketBase Response Data:', err.response.data); onSuccess(data);
// Attempt to extract specific error messages from PocketBase response } catch (error) {
const apiErrors = []; console.error('Error creating media:', error);
for (const field in err.response.data) { setError(error.message);
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 { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return ( return (
<div className="p-6 bg-campfire-charcoal rounded-lg shadow-md text-campfire-light max-h-[80vh] overflow-y-auto custom-scrollbar"> <div className="fixed inset-0 bg-campfire-charcoal/80 flex items-center justify-center p-4 z-50">
<h2 className="text-2xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4"> <div className="bg-campfire-dark rounded-lg shadow-xl max-w-2xl w-full p-6">
{isEditing ? 'Редактировать контент' : 'Добавить новый контент'} <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-campfire-light">
Добавить новый контент
</h2> </h2>
<button
onClick={onCancel}
className="text-campfire-ash hover:text-campfire-light"
>
</button>
</div>
{errors.length > 0 && ( {error && (
<div className="bg-status-error/20 text-status-error p-4 rounded-md mb-6 border border-status-error/30"> <div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg mb-6">
<ul className="list-disc list-inside"> {error}
{errors.map((err, index) => (
<li key={index}>{err}</li>
))}
</ul>
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Basic Info */}
<div> <div>
<label htmlFor="title" className="block text-sm font-medium text-campfire-light mb-1"> <label className="block text-sm font-medium text-campfire-light mb-1">
Название <span className="text-red-500">*</span> Название
</label> </label>
<input <input
type="text" type="text"
id="title" name="title"
value={title} value={formData.title}
onChange={(e) => setTitle(e.target.value)} onChange={handleChange}
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" className="input w-full"
required required
/> />
</div> </div>
<div> <div>
<label htmlFor="path" className="block text-sm font-medium text-campfire-light mb-1"> <label className="block text-sm font-medium text-campfire-light mb-1">
Путь (URL Slug) <span className="text-red-500">*</span> Тип
</label> </label>
<input <select
type="text" name="type"
id="path" value={formData.type}
value={path} onChange={handleChange}
onChange={(e) => setPath(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" required
>
<option value="movie">Фильм</option>
<option value="tv">Сериал</option>
<option value="game">Игра</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-campfire-light mb-1">
Описание
</label>
<textarea
name="overview"
value={formData.overview}
onChange={handleChange}
className="input w-full h-32"
required required
/> />
<p className="mt-1 text-sm text-campfire-ash">
Используется в URL (например, `/media/ваш-путь`). Только латинские буквы, цифры и дефисы.
</p>
</div> </div>
<div> <div>
<label htmlFor="type" className="block text-sm font-medium text-campfire-light mb-1"> <label className="block text-sm font-medium text-campfire-light mb-1">
Тип <span className="text-red-500">*</span>
</label>
<select
id="type"
value={type}
onChange={(e) => setType(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
required
>
<option value="">Выберите тип</option>
{Object.entries(mediaTypes).map(([key, value]) => (
<option key={key} value={key}>{value.label}</option>
))}
</select>
</div>
{/* Progress Type Selection */}
<div>
<label htmlFor="progress_type" className="block text-sm font-medium text-campfire-light mb-1">
Тип отслеживания прогресса <span className="text-red-500">*</span>
</label>
<select
id="progress_type"
value={progressType}
onChange={(e) => setProgressType(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
required
>
{/* Removed default empty option, 'completed' is now the default state */}
{progressTypeOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<p className="mt-1 text-sm text-campfire-ash">
Определяет, как пользователи будут отмечать прогресс (часы, просмотрено/нет, пройдено/нет).
</p>
</div>
{/* Release Date */}
<div>
<label htmlFor="release_date" className="block text-sm font-medium text-campfire-light mb-1">
Дата выхода Дата выхода
</label> </label>
<input <input
type="date" type="date"
id="release_date" name="release_date"
value={releaseDate} value={formData.release_date}
onChange={(e) => setReleaseDate(e.target.value)} onChange={handleChange}
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" className="input w-full"
/>
</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 required
/> />
</div> </div>
{/* Label Input */}
<div> <div>
<label htmlFor={`char-label-${char.id}`} className="block text-xs font-medium text-campfire-ash mb-1"> <label className="block text-sm font-medium text-campfire-light mb-1">
Название (для пользователей) URL постера
</label> </label>
<input <input
type="text" type="url"
id={`char-label-${char.id}`} name="poster_url"
value={char.label} value={formData.poster_url}
onChange={(e) => handleCharacteristicLabelChange(char.id, e.target.value)} onChange={handleChange}
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" className="input w-full"
placeholder="e.g., Сюжет"
required required
/> />
</div> </div>
</div>
{characteristics.length > 6 && ( // Only show remove if more than 6 <div>
<button <label className="block text-sm font-medium text-campfire-light mb-1">
type="button" URL фона
onClick={() => handleRemoveCharacteristic(char.id)} </label>
className="text-status-error hover:text-red-700 transition-colors p-1" <input
aria-label={`Remove characteristic ${char.label}`} type="url"
> name="backdrop_url"
<FaTimesCircle size={18} /> {/* Slightly smaller icon */} value={formData.backdrop_url}
</button> onChange={handleChange}
)} className="input w-full"
</div> />
))}
</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"> <div className="flex items-center">
<input <input
type="checkbox" type="checkbox"
id="is_published" name="is_published"
checked={isPublished} checked={formData.is_published}
onChange={(e) => setIsPublished(e.target.checked)} 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" className="mr-2"
/> />
<label htmlFor="is_published" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer"> <label className="text-sm font-medium">Опубликовать сразу</label>
Опубликовано
</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>
<div className="flex justify-end gap-4 mt-6">
{/* Submit Button */} <button
<div className="flex justify-end mt-6"> type="button"
onClick={onCancel}
className="btn-secondary"
>
Отмена
</button>
<button <button
type="submit" type="submit"
className="btn-primary"
disabled={loading} disabled={loading}
className={`btn-primary ${loading ? 'opacity-50 cursor-not-allowed' : ''} transition-colors duration-200`}
> >
{loading ? (isEditing ? 'Сохранение...' : 'Создание...') : (isEditing ? 'Сохранить изменения' : 'Создать контент')} {loading ? 'Создание...' : 'Создать'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div>
); );
} };
export default MediaForm; export default MediaForm;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { useState, useEffect } from 'react';
import { FaDiscord, FaTelegramPlane, FaFire } from "react-icons/fa"; import { FaDiscord, FaTelegramPlane, FaFire } from "react-icons/fa";
import { import {
RiOpenaiFill, RiOpenaiFill,
@ -6,63 +5,40 @@ import {
RiStackOverflowLine, RiStackOverflowLine,
} from "react-icons/ri"; } from "react-icons/ri";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Logo from "../ui/Logo";
function Footer() { function Footer() {
const currentYear = new Date().getFullYear(); 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 ( return (
<footer className="bg-campfire-darker py-12 mt-20"> <footer className="bg-campfire-charcoal py-12 mt-20">
<div className="container-custom"> <div className="container-custom">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */} {/* Brand */}
<div className="col-span-1 md:col-span-1"> <div className="col-span-1 md:col-span-1">
<Link to="/" className="flex items-center"> <Link to="/" className="flex items-center">
{showTextLogo ? ( <Logo size="small" />
<span className="text-campfire-primary text-xl font-bold">CampFire мнеие</span> <span className="ml-2 text-xl font-bold">CampFire Critics</span>
) : (
<img
src={logoSrc}
alt="CampFire мнеие"
className="h-8 object-contain"
onError={handleExternalLogoError}
/>
)}
</Link> </Link>
<p className="mt-4 text-campfire-light/60"> <p className="mt-4 text-campfire-ash">
Делаем хорошо, но на отъебись. Делаем хорошо, но на отъебись.
</p> </p>
<div className="flex mt-6 space-x-4"> <div className="flex mt-6 space-x-4">
<a <a
href="#" href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
<FaDiscord size={20} /> <FaDiscord size={20} />
</a> </a>
<a <a
href="#" href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
<FaTelegramPlane size={20} /> <FaTelegramPlane size={20} />
</a> </a>
<a <a
href="#" href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
<FaFire size={20} /> <FaFire size={20} />
</a> </a>
@ -71,93 +47,85 @@ function Footer() {
{/* Navigation */} {/* Navigation */}
<div className="col-span-1"> <div className="col-span-1">
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Атлас</h3> <h3 className="text-lg font-semibold mb-4">Атлас</h3>
<ul className="space-y-2"> <ul className="space-y-2">
<li> <li>
<Link <Link
to="/" to="/"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
Главная Главная
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
to="/catalog?type=movie" to="/discover/movies"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
Фильмы Фильмы
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
to="/catalog?type=tv" to="/discover/tv"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
Сериалы Сериалы
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
to="/catalog?type=game" to="/discover/games"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
Игры Игры
</Link> </Link>
</li> </li>
<li>
<Link
to="/catalog?type=anime"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors"
>
Аниме
</Link>
</li>
</ul> </ul>
</div> </div>
{/* Legal */} {/* Legal */}
<div className="col-span-1"> <div className="col-span-1">
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Правовая информация</h3> <h3 className="text-lg font-semibold mb-4">Правовая информация</h3>
<ul className="space-y-2"> <ul className="space-y-2">
<li> <li>
<Link <Link
to="/privacy-policy" to="/terms"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
>
Политика конфиденциальности
</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> </Link>
</li> </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> </ul>
</div> </div>
{/* Contact */} {/* Contact */}
<div className="col-span-1"> <div className="col-span-1">
<h3 className="text-lg font-semibold mb-4 text-campfire-light">Контакты</h3> <h3 className="text-lg font-semibold mb-4">Контакты</h3>
<ul className="space-y-2"> <ul className="space-y-2">
<li className="text-campfire-light/60"> <li className="text-campfire-ash">
<span>general@campfiregg.ru</span> <span>general@campfiregg.ru</span>
</li> </li>
<li> <li>
<Link <Link
to="/support" to="/contact"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
Связаться с нами Связаться с нами
</Link> </Link>
@ -165,7 +133,7 @@ function Footer() {
<li> <li>
<Link <Link
to="/faq" to="/faq"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors" className="text-campfire-ash hover:text-campfire-amber"
> >
FAQ FAQ
</Link> </Link>
@ -174,14 +142,14 @@ function Footer() {
</div> </div>
</div> </div>
<div className="border-t border-campfire-dark mt-12 pt-8 text-center text-campfire-light/60"> <div className="border-t border-campfire-dark mt-12 pt-8 text-center text-campfire-ash">
<p> <p>
&copy; {currentYear} CampFire мнеие. Никакие права не защищены. &copy; {currentYear} CampFire Critics. Почти все права защищены.
</p> </p>
<p className="mt-2 text-sm"> <p className="mt-2 text-sm">
<a <a
href="#" href="#"
className="text-campfire-light/60 hover:text-campfire-primary transition-colors inline-flex items-center" className="text-campfire-ash hover:text-campfire-amber inline-flex items-center"
> >
VibeCoded with VibeCoded with
<RiStackOverflowLine className="ml-1" size={20} /> <RiStackOverflowLine className="ml-1" size={20} />

View File

@ -1,389 +1,209 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from "../../contexts/AuthContext";
import { useProfileActions } from '../../contexts/ProfileActionsContext'; import { FiSearch, FiMenu, FiX, FiUser } from "react-icons/fi";
import { getFileUrl } from '../../services/pocketbaseService'; import SearchBar from "../ui/SearchBar";
import { FaUserCircle, FaSignOutAlt, FaEdit, FaBars, FaTimes, FaTachometerAlt, FaBook, FaTrophy, FaCog, FaHeadset, FaHome, FaUser } from 'react-icons/fa'; // Import FaBook, FaTrophy, FaCog, and FaHeadset import Logo from "../ui/Logo";
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 Header = () => {
const { user, userProfile, signOut } = useAuth(); const { user, userProfile, signOut } = useAuth();
const profileActions = useProfileActions();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenu] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = 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(() => { useEffect(() => {
const img = new Image(); setIsMenuOpen(false);
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); setIsSearchOpen(false);
}; }, [location.pathname]);
// Close menus/search when clicking outside // Handle scroll effect for header
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleScroll = () => {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) { if (window.scrollY > 10) {
setIsProfileMenuOpen(false); setIsScrolled(true);
} } else {
if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target)) { setIsScrolled(false);
setIsMobileMenu(false);
}
// Close search only if clicking outside the search container itself
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
// Check if the click was NOT on the search toggle button
const searchButton = document.getElementById('search-toggle-button');
if (searchButton && searchButton !== event.target && !searchButton.contains(event.target)) {
setIsSearchOpen(false);
}
} }
}; };
document.addEventListener('mousedown', handleClickOutside); window.addEventListener("scroll", handleScroll);
return () => { return () => window.removeEventListener("scroll", handleScroll);
document.removeEventListener('mousedown', handleClickOutside);
};
}, []); }, []);
// Handle Edit Profile click from Header const handleLogout = async () => {
const handleEditProfileClick = () => { try {
if (userProfile) { await signOut();
console.log('Header: Edit Profile clicked. Navigating to profile and triggering modal.'); navigate("/");
navigate(`/profile/${userProfile.username}`); } catch (error) {
if (profileActions?.triggerEditModal) { console.error("Не удалось выйти:", error);
profileActions.triggerEditModal();
}
closeMenus();
} else {
console.log('Header: Edit Profile clicked, but userProfile is not available.');
} }
}; };
const canAccessAdmin = canManageSystem(user);
const mainItems = [
{
icon: <FaHome className="w-5 h-5 text-campfire-light" />,
label: 'Главная',
onClick: () => navigate('/'),
className: location.pathname === '/' ? 'bg-campfire-amber/20' : ''
},
{
icon: <FaBook className="w-5 h-5 text-campfire-light" />,
label: 'Каталог',
onClick: () => navigate('/catalog'),
className: location.pathname === '/catalog' ? 'bg-campfire-amber/20' : ''
},
{
icon: <FaTrophy className="w-5 h-5 text-campfire-light" />,
label: 'Рейтинги',
onClick: () => navigate('/rating'),
className: location.pathname === '/rating' ? 'bg-campfire-amber/20' : ''
},
{
icon: <FiSearch className="w-5 h-5 text-campfire-light" />,
label: 'Поиск',
onClick: toggleSearch,
className: isSearchOpen ? 'bg-campfire-amber/20' : ''
}
];
const profileItems = [
{
icon: (
<div className="flex items-center gap-2 px-2">
<img
src={userProfile?.profile_picture ? getFileUrl(userProfile, 'profile_picture') : 'https://pocketbase.campfiregg.ru/api/files/_pb_users_auth_/g520s25pzm0t6e1/photo_2025_05_17_22_26_21_g2bi9umsuu.jpg'}
alt={userProfile?.username || 'User'}
className="w-6 h-6 rounded-full object-cover border border-campfire-ash/30"
/>
<span className="text-[10px] text-campfire-light truncate max-w-[60px]">{userProfile?.username || 'Профиль'}</span>
</div>
),
onClick: () => navigate(userProfile ? `/profile/${userProfile.username}` : '/auth/login'),
className: location.pathname.startsWith('/profile') ? 'bg-campfire-amber/20' : '',
showLabel: false,
isRectangular: true
},
{
icon: <FaBars className="w-5 h-5 text-campfire-light" />,
label: 'Меню',
onClick: toggleProfileMenu,
className: isProfileMenuOpen ? 'bg-campfire-amber/20' : ''
}
];
return ( return (
<> <header
<header className="fixed top-2 left-0 right-0 z-50"> className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
<div className="bg-campfire-charcoal/80 backdrop-blur-md border-b border-campfire-ash/20 rounded-t-lg"> isScrolled ? "bg-campfire-dark shadow-lg py-2" : "bg-transparent py-4"
<div className="container-custom flex items-center justify-between h-16 px-4"> }`}
>
<div className="container-custom">
<div className="flex items-center justify-between">
{/* Logo */} {/* Logo */}
<Link to="/" className="flex items-center space-x-2"> <Link to="/" className="flex items-center">
<img <Logo />
src={logoSrc} <span className="ml-2 text-xl font-bold hidden sm:block">
alt="CampFire мнение" CampFire Critics
className="h-12" </span>
onError={handleExternalLogoError}
/>
</Link> </Link>
{/* Main Dock Navigation */} {/* Desktop Navigation */}
<div className="hidden md:flex flex-1 justify-center"> <nav className="hidden md:flex items-center space-x-6">
<div className="flex items-center gap-4"> <Link
<Dock to="/"
items={mainItems} className="text-campfire-light hover:text-campfire-amber transition-colors"
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} /> </Link>
</div> <Link
</motion.div> 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>
{/* 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 ? (
<img
src={userProfile.profilePicture}
alt={userProfile.username}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<FiUser size={20} className="text-campfire-light" />
)} )}
</AnimatePresence> </button>
</div> <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">
</div> {userProfile?.role === 'admin' && (
<Link
{/* Profile Dock */} to="/admin/media"
<div className="hidden md:flex relative"> className="block px-4 py-2 hover:bg-campfire-dark"
<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>
)}
<Link <Link
to={`/profile/${userProfile?.username}`} to={`/profile/${userProfile?.username}`}
onClick={closeMenus} className="block px-4 py-2 hover:bg-campfire-dark"
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> </Link>
{canAccessAdmin && (
<Link
to="/admin"
onClick={closeMenus}
className="block px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaTachometerAlt />
<span>Админ панель</span>
</Link>
)}
{userProfile && (
<button <button
onClick={handleEditProfileClick} onClick={handleLogout}
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" className="block w-full text-left px-4 py-2 hover:bg-campfire-dark"
> >
<FaEdit /> Выйти
<span>Редактировать профиль</span>
</button> </button>
)} </div>
{user && ( </div>
) : (
<> <>
<Link <Link
to="/support" to="/login"
className="block px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2" className="text-campfire-light hover:text-campfire-amber transition-colors"
> >
<FaHeadset /> Войти
<span>Поддержка</span>
</Link> </Link>
<button <Link
onClick={() => { to="/register"
signOut(); className="btn-primary"
closeMenus();
}}
className="block w-full text-left px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
> >
<FaSignOutAlt /> Регистрация
<span>Выйти</span> </Link>
</button>
</> </>
)} )}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<div className="md:hidden"> <button
<button onClick={toggleMobileMenu} className="text-campfire-light focus:outline-none"> className="md:hidden p-2 text-campfire-light"
{isMobileMenuOpen ? <FaTimes size={24} /> : <FaBars size={24} />} onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label="Меню"
>
{isMenuOpen ? <FiX size={24} /> : <FiMenu size={24} />}
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Search Results Dropdown */} {/* Search Bar (expandable) */}
<AnimatePresence> <div
{isSearchOpen && ( className={`mt-4 transition-all duration-300 overflow-hidden ${
<motion.div isSearchOpen ? "max-h-20 opacity-100" : "max-h-0 opacity-0"
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"> <SearchBar onClose={() => setIsSearchOpen(false)} />
{/* Здесь будет компонент с результатами поиска */}
</div> </div>
</motion.div>
)}
</AnimatePresence>
{/* Mobile Menu */} {/* Mobile Menu */}
<AnimatePresence> <div
{isMobileMenuOpen && ( className={`md:hidden transition-all duration-300 overflow-hidden ${
<motion.nav isMenuOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
initial={{ opacity: 0, height: 0 }} }`}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
ref={mobileMenuRef}
className="md:hidden bg-campfire-charcoal border-t border-campfire-ash/20"
> >
<div className="container-custom py-4 px-4"> <nav className="mt-4 flex flex-col space-y-3 pb-4">
<NavLink <Link
to="/catalog" to="/"
onClick={closeMenus} className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
className={({ isActive }) =>
`block px-4 py-2 hover:bg-campfire-ash/20 transition-colors ${isActive ? 'text-campfire-amber' : 'text-campfire-light'} flex items-center space-x-2`
}
> >
<FaBook size={20} /> Главная
<span>Каталог</span> </Link>
</NavLink> <Link
<NavLink to="/discover/movies"
to="/rating" className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
onClick={closeMenus}
className={({ isActive }) =>
`block px-4 py-2 hover:bg-campfire-ash/20 transition-colors ${isActive ? 'text-campfire-amber' : 'text-campfire-light'} flex items-center space-x-2`
}
> >
<FaTrophy size={20} /> Фильмы
<span>Рейтинги</span> </Link>
</NavLink> <Link
{user && ( to="/discover/series"
<button className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
onClick={() => {
signOut();
closeMenus();
}}
className="block w-full text-left px-4 py-2 text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
> >
<FaSignOutAlt /> Сериалы
<span>Выйти</span> </Link>
</button> <Link
)} to="/discover/games"
className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
>
Игры
</Link>
</nav>
</div>
</div> </div>
</motion.nav>
)}
</AnimatePresence>
</header> </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 />
</>
); );
}; };

View File

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

View File

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

View File

@ -1,84 +1,32 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { FaFire } from 'react-icons/fa'; // Changed FaStar to FaFire import { FaStar } from 'react-icons/fa';
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 ( return (
<Link to={mediaLink} className="block"> <Link to={`/media/${id}`} 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="card group h-full">
<div className="relative overflow-hidden aspect-[2/3]"> <div className="relative overflow-hidden aspect-[2/3]">
{/* Используем posterUrl, полученный через getFileUrl */}
<img <img
src={posterUrl} src={poster}
alt={title} alt={title}
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-300" className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-300"
loading="lazy" 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"> <div className="absolute top-2 right-2 bg-campfire-dark bg-opacity-75 rounded-full px-2 py-1 flex items-center">
<FaFire className="text-campfire-amber mr-1" size={14} /> {/* Changed FaStar to FaFire */} <FaStar className="text-campfire-amber mr-1" size={14} />
<span className="text-sm font-medium text-campfire-light"> <span className="text-sm font-medium">
{/* Форматируем average_rating до одной десятичной */} {rating ? (rating / 2).toFixed(1) : 'N/A'}
{parseFloat(average_rating).toFixed(1)} / 10
</span> </span>
{/* Optionally display review count */}
{/* {review_count !== null && review_count !== undefined && (
<span className="text-xs text-campfire-ash ml-1">({review_count})</span>
)} */}
</div> </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>
<div className="p-4"> <div className="p-4">
<h3 className="font-bold text-campfire-light line-clamp-1 mb-1 group-hover:text-campfire-amber transition-colors"> <h3 className="font-bold text-campfire-light line-clamp-1 mb-1">
{title} {title}
</h3> </h3>
<div className="text-sm text-campfire-ash"> <div className="text-sm text-campfire-ash">
{releaseDate ? new Date(releaseDate).getFullYear() : 'N/A'} {new Date(releaseDate).getFullYear()}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,30 +1,76 @@
import React from 'react'; import { useRef } from 'react';
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard'; import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import MediaCard from './MediaCard';
// Simple Media Carousel component function MediaCarousel({ title, media = [], mediaType = 'movie', seeAllLink }) {
const MediaCarousel = ({ media, userProfile }) => { const carouselRef = useRef(null);
if (!media || media.length === 0) {
return <div className="text-campfire-ash text-center">Нет контента для отображения.</div>; 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;
} }
return ( return (
// Use flexbox for horizontal layout and overflow-x-auto for scrolling <div className="my-8">
// Add padding-bottom to prevent scrollbar from covering content <div className="flex justify-between items-center mb-4">
<div className="flex overflow-x-auto space-x-8 pb-4 scrollbar-thin scrollbar-thumb-campfire-amber scrollbar-track-campfire-charcoal"> <h2 className="text-2xl font-bold">{title}</h2>
{media.map((mediaItem) => ( {seeAllLink && (
// Wrap MediaCard in a div with flex-shrink-0 to prevent shrinking <a href={seeAllLink} className="text-campfire-amber hover:text-campfire-ember">
// Set a fixed width for each card in the carousel See All
<div key={mediaItem.id} className="flex-shrink-0 w-40 sm:w-48 md:w-56 lg:w-64"> {/* Adjust width as needed */} </a>
<TiltedCard )}
imageSrc={mediaItem.poster_path} </div>
captionText={mediaItem.title}
rating={mediaItem.rating} <div className="relative">
releaseDate={mediaItem.release_date} {/* 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} />
</div> </div>
))} ))}
</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; export default MediaCarousel;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import React from 'react';
import { import {
Chart as ChartJS, Chart as ChartJS,
RadialLinearScale, RadialLinearScale,
@ -6,10 +5,11 @@ import {
LineElement, LineElement,
Filler, Filler,
Tooltip, Tooltip,
Legend, Legend
} from 'chart.js'; } from 'chart.js';
import { Radar } from 'react-chartjs-2'; import { Radar } from 'react-chartjs-2';
// Register ChartJS components
ChartJS.register( ChartJS.register(
RadialLinearScale, RadialLinearScale,
PointElement, PointElement,
@ -19,120 +19,111 @@ ChartJS.register(
Legend Legend
); );
// Define colors based on your theme function RatingChart({ ratings, size = 'medium', showLegend = false }) {
const chartColors = { // Default ratings if none provided
amber: '#FF9D00', // Campfire Amber const defaultRatings = {
dark: '#1A202C', // Campfire Dark story: 0,
ash: '#A0AEC0', // Campfire Ash visuals: 0,
light: '#F7FAFC', // Campfire Light performance: 0,
charcoal: '#2D3748', // Campfire Charcoal soundtrack: 0,
enjoyment: 0
}; };
// Merge provided ratings with defaults
const mergedRatings = { ...defaultRatings, ...ratings };
function RatingChart({ ratings, labels, size = 'medium' }) { // Chart data
// 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 = { const data = {
labels: chartLabels, labels: ['Story', 'Visuals', 'Performance', 'Soundtrack', 'Enjoyment'],
datasets: [ datasets: [
{ {
label: 'Оценка', // Or 'Средняя оценка' depending on context label: 'Rating',
data: chartDataValues, data: [
backgroundColor: `${chartColors.amber}40`, // Amber with transparency mergedRatings.story,
borderColor: chartColors.amber, mergedRatings.visuals,
pointBackgroundColor: chartColors.amber, mergedRatings.performance,
pointBorderColor: chartColors.light, mergedRatings.soundtrack,
pointHoverBackgroundColor: chartColors.light, mergedRatings.enjoyment
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 = { 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: { scales: {
r: { r: {
angleLines: { angleLines: {
display: true, display: true,
color: chartColors.ash + '40', // Ash with transparency color: 'rgba(255, 255, 255, 0.1)',
},
suggestedMin: 0,
suggestedMax: 10,
ticks: {
stepSize: 2,
backdropColor: 'transparent',
color: 'rgba(255, 255, 255, 0.7)',
}, },
grid: { grid: {
color: chartColors.ash + '40', // Ash with transparency color: 'rgba(255, 255, 255, 0.1)',
}, },
pointLabels: { pointLabels: {
color: chartColors.light, // Light text for labels color: 'rgba(255, 255, 255, 0.7)',
font: { font: {
size: size === 'small' ? 8 : (size === 'medium' ? 10 : 12), // Adjust font size size: size === 'small' ? 8 : 12,
},
},
} }
}, },
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: { plugins: {
legend: { legend: {
display: false, // Hide legend display: showLegend,
}, },
tooltip: { 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: { callbacks: {
label: function(context) { label: function(context) {
let label = context.dataset.label || ''; return `Rating: ${context.raw}/10`;
if (label) {
label += ': ';
}
if (context.raw !== null) {
label += context.raw.toFixed(1); // Show rating with one decimal place
}
return label;
} }
} }
} }
}, },
elements: { elements: {
line: { line: {
tension: 0.1 // Add some tension for smoother lines tension: 0.2
}
} }
},
responsive: true,
maintainAspectRatio: true,
}; };
// Adjust container size based on the 'size' prop // Size classes
const containerClasses = size === 'small' const sizeClasses = {
? 'w-32 h-32' // Smaller size for individual review items small: 'w-32 h-32',
: size === 'medium' medium: 'w-64 h-64',
? 'w-48 h-48 md:w-64 md:h-64' // Medium size for form preview large: 'w-96 h-96'
: 'w-64 h-64 md:w-80 md:h-80 lg:w-96 lg:h-96'; // Large size for media page aggregate chart };
return ( return (
<div className={`relative mx-auto ${containerClasses}`}> <div className={`${sizeClasses[size]} mx-auto`}>
<Radar data={data} options={options} /> <Radar data={data} options={options} />
</div> </div>
); );

View File

@ -3,26 +3,22 @@ import { Link } from 'react-router-dom';
import { FaThumbsUp, FaComment, FaShare } from 'react-icons/fa'; import { FaThumbsUp, FaComment, FaShare } from 'react-icons/fa';
import RatingChart from './RatingChart'; import RatingChart from './RatingChart';
// Accept characteristics prop function ReviewCard({ review, isDetailed = false }) {
function ReviewCard({ review, isDetailed = false, characteristics }) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const { const {
id, id,
users, // Changed from user to users user,
content, content,
ratings, ratings,
likes, likes,
comments, comments,
created_at, createdAt,
spoiler spoiler
} = review; } = review;
// Use users object and rename for clarity
const userProfile = users;
// Format date // Format date
const formattedDate = new Date(created_at).toLocaleDateString('ru-RU', { const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@ -40,18 +36,18 @@ function ReviewCard({ review, isDetailed = false, characteristics }) {
{/* Review Header */} {/* Review Header */}
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div className="flex items-center"> <div className="flex items-center">
<Link to={`/profile/${userProfile?.username}`}> {/* Use userProfile?.id */} <Link to={`/profile/${user.id}`}>
<img <img
src={userProfile?.profile_picture || 'https://questhowth.ie/wp-content/uploads/2018/04/user-placeholder.png'} // Use userProfile?.profile_picture src={user.profilePicture || 'https://via.placeholder.com/40'}
alt={userProfile?.username} // Use userProfile?.username alt={user.username}
className="w-10 h-10 rounded-full object-cover mr-3" className="w-10 h-10 rounded-full object-cover mr-3"
/> />
</Link> </Link>
<div> <div>
<Link to={`/profile/${userProfile?.username}`} className="font-medium text-campfire-light hover:text-campfire-amber"> {/* Use userProfile?.id */} <Link to={`/profile/${user.id}`} className="font-medium text-campfire-light hover:text-campfire-amber">
{userProfile?.username} {/* Use userProfile?.username */} {user.username}
</Link> </Link>
{userProfile?.is_critic && ( // Use userProfile?.is_critic {user.isCritic && (
<span className="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-campfire-amber text-campfire-dark rounded-full"> <span className="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-campfire-amber text-campfire-dark rounded-full">
Critic Critic
</span> </span>
@ -67,13 +63,8 @@ function ReviewCard({ review, isDetailed = false, characteristics }) {
</div> </div>
{/* Review Content */} {/* Review Content */}
{/* Ensure grid is applied on md screens when detailed */} <div className={`${isDetailed ? 'grid grid-cols-1 md:grid-cols-3 gap-8' : ''}`}>
{/* Changed grid-cols-3 to grid-cols-2 for 2 columns */} <div className={`${isDetailed ? 'md:col-span-2' : ''}`}>
<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 Warning */}
{spoiler && ( {spoiler && (
<div className="bg-status-warning bg-opacity-20 text-status-warning p-3 rounded-md mb-4"> <div className="bg-status-warning bg-opacity-20 text-status-warning p-3 rounded-md mb-4">
@ -98,16 +89,11 @@ function ReviewCard({ review, isDetailed = false, characteristics }) {
</div> </div>
{/* Rating Chart */} {/* Rating Chart */}
{/* Ensure chart takes 1 column on md screens when detailed */} <div className={`${isDetailed ? 'md:col-span-1' : 'hidden md:block'}`}>
{/* 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 <RatingChart
ratings={ratings} ratings={ratings}
// Pass characteristics as labels to RatingChart size={isDetailed ? 'medium' : 'small'}
labels={characteristics}
size={isDetailed ? 'big' : 'medium'} // Reverted size to 'big' for detailed view
showLegend={isDetailed} showLegend={isDetailed}
isDetailed={isDetailed} // Ensure prop is passed
/> />
</div> </div>
</div> </div>
@ -120,9 +106,8 @@ function ReviewCard({ review, isDetailed = false, characteristics }) {
<span>{likes}</span> <span>{likes}</span>
</button> </button>
<button className="flex items-center text-campfire-ash hover:text-campfire-amber"> <button className="flex items-center text-campfire-ash hover:text-campfire-amber">
{/* Safely access comments length */}
<FaComment className="mr-2" /> <FaComment className="mr-2" />
<span>{comments?.length || 0}</span> <span>{comments.length}</span>
</button> </button>
</div> </div>
<button className="flex items-center text-campfire-ash hover:text-campfire-amber"> <button className="flex items-center text-campfire-ash hover:text-campfire-amber">

View File

@ -1,465 +1,120 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { FaFire, FaEdit, FaTrashAlt } from 'react-icons/fa'; // Changed FaStar to FaFire import { FaStar } from 'react-icons/fa';
import RatingChart from './RatingChart'; 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 [content, setContent] = useState('');
const [hasSpoilers, setHasSpoilers] = useState(false); 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 [isSubmitting, setIsSubmitting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
// New state for selected season in the form (only relevant if media supports seasons) const labels = {
// Initialize with the selectedSeasonId passed from the parent story: 'Story & Writing',
const [formSeasonId, setFormSeasonId] = useState(selectedSeasonId); visuals: 'Visuals & Effects',
performance: 'Acting/Performance',
soundtrack: 'Sound & Music',
// Determine the correct progress options/label based *only* on progressType enjoyment: 'Overall Enjoyment'
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) => { const handleRatingChange = (category, value) => {
setRatings(prev => ({ setRatings(prev => ({
...prev, ...prev,
[category]: value // Store the number directly from FlameRatingInput [category]: value
})); }));
}; };
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) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Validate that all characteristics have a rating (since N/A is removed) // Calculate overall rating
const allRatingsValid = Object.keys(characteristics).every(key => const overallRating = Object.values(ratings).reduce((sum, rating) => sum + rating, 0) / Object.keys(ratings).length;
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 = { const reviewData = {
media_id: mediaId, mediaId,
season_id: formSeasonId, // Use the season selected in the form (can be null) mediaType,
media_type: mediaType, content,
content, // Use content from Quill editor state (HTML string) ratings,
ratings, // Store the ratings object { [key]: number } overallRating,
has_spoilers: hasSpoilers, hasSpoilers,
progress, // Include progress (text field) createdAt: new Date().toISOString()
}; };
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); 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) { } catch (error) {
console.error('Error submitting review:', error); console.error('Error submitting review:', error);
// Optionally set an error state to display to the user
} finally { } finally {
setIsSubmitting(false); 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 ( return (
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6 text-center border border-campfire-ash/20"> <div className="bg-campfire-charcoal rounded-lg shadow-md p-6">
<p className="text-campfire-light mb-4">Вы уже написали рецензию на это произведение.</p> <h2 className="text-xl font-bold mb-6">Write Your Review</h2>
<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}> <form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-6">
<div className="md:col-span-2"> <div className="md:col-span-2">
{/* Rating Sliders */}
{/* Season Selection (if media supports seasons) */} <div className="space-y-6 mb-6">
{supportsSeasons && seasons.length > 0 && ( {Object.keys(ratings).map(category => (
<div className="mb-6"> <div key={category} className="space-y-2">
<label htmlFor="season-select" className="block mb-2 text-campfire-light"> <div className="flex justify-between">
Сезон <span className="text-red-500">*</span> <label className="text-campfire-light">{labels[category]}</label>
</label> <span className="flex items-center text-campfire-amber">
<select <FaStar className="mr-1" />
id="season-select" {ratings[category]}
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> </span>
</div> </div>
{/* Flame Rating Input Component */} <input
<FlameRatingInput type="range"
value={ratings[key] !== undefined ? ratings[key] : 5} min="1"
onChange={(value) => handleRatingChange(key, value)} 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%)`
}}
/> />
</div> </div>
))} ))}
</div> </div>
{/* Review Text - Using ReactQuill */} {/* Review Text */}
<div className="mb-6"> <div className="mb-6">
<label htmlFor="review-content" className="block mb-2 text-campfire-light text-sm font-medium"> <label htmlFor="review-content" className="block mb-2 text-campfire-light">
Ваша рецензия <span className="text-red-500">*</span> Your Review
</label> </label>
<ReactQuill <textarea
theme="snow" // Use the snow theme id="review-content"
rows="8"
placeholder="Share your thoughts on this title..."
value={content} value={content}
onChange={setContent} onChange={(e) => setContent(e.target.value)}
modules={modules} 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"
formats={formats} required
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> </div>
{/* Spoiler Checkbox */} {/* Spoiler Checkbox */}
@ -469,51 +124,31 @@ function ReviewForm({ mediaId, seasonId, mediaType, progressType, onSubmit, onEd
id="spoiler-check" id="spoiler-check"
checked={hasSpoilers} checked={hasSpoilers}
onChange={(e) => setHasSpoilers(e.target.checked)} onChange={(e) => setHasSpoilers(e.target.checked)}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2 cursor-pointer" className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2"
/> />
<label htmlFor="spoiler-check" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer"> <label htmlFor="spoiler-check" className="ml-2 text-campfire-light">
Эта рецензия содержит спойлеры This review contains spoilers
</label> </label>
</div> </div>
</div> </div>
{/* Rating Chart Preview */} {/* Rating Chart Preview */}
<div className="md:col-span-1"> <div className="md:col-span-1">
<p className="text-center text-campfire-light mb-4 font-semibold">Предварительный просмотр вашей оценки</p> <p className="text-center text-campfire-light mb-4">Your Rating Preview</p>
<RatingChart <RatingChart ratings={ratings} size="medium" />
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>
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<div className="flex justify-end mt-6"> {/* Added mt-6 for spacing */} <div className="flex justify-end">
<button <button
type="submit" type="submit"
disabled={isSubmitting || !isFormValid} disabled={isSubmitting}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="btn-primary"
> >
{isSubmitting ? (existingReview ? 'Сохранение...' : 'Отправка...') : (existingReview ? 'Сохранить изменения' : 'Отправить рецензию')} {isSubmitting ? 'Submitting...' : 'Submit Review'}
</button> </button>
</div> </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> </form>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,103 +1,119 @@
import React, { useState, useEffect, useRef } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiSearch, FiX } from 'react-icons/fi'; import { FiSearch, FiX } from 'react-icons/fi';
import { searchMedia } from '../../services/pocketbaseService'; import { useMedia } from '../../contexts/MediaContext';
import SearchResults from './SearchResults';
const SearchBar = ({ onClose }) => { function SearchBar({ onClose }) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState([]); const { handleSearch, searchResults, loading } = useMedia();
const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate();
const inputRef = useRef(null);
// Фокус на инпут при монтировании const handleSubmit = (e) => {
useEffect(() => { e.preventDefault();
const timer = setTimeout(() => { if (query.trim()) {
if (inputRef.current) { navigate(`/search?q=${encodeURIComponent(query)}`);
inputRef.current.focus(); if (onClose) onClose();
}
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (query.length >= 2) {
setIsLoading(true);
searchMedia(query)
.then((data) => {
setResults(data);
setIsLoading(false);
})
.catch((error) => {
console.error('Search error:', error);
setIsLoading(false);
});
} else {
setResults([]);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [query]);
const handleInputChange = (e) => {
const value = e.target.value;
// Если это первый символ, делаем его заглавным
if (value.length === 1) {
setQuery(value.toUpperCase());
} else {
setQuery(value);
} }
}; };
const handleResultClick = () => { const handleChange = (e) => {
onClose(); // Закрываем поиск при клике на результат const value = e.target.value;
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();
}; };
return ( return (
<div className="relative"> <div className="relative">
<div className="flex items-center gap-2"> <form onSubmit={handleSubmit} className="relative">
<div className="relative flex-1">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-campfire-ash" />
<input <input
ref={inputRef}
type="text" type="text"
value={query} value={query}
onChange={handleInputChange} onChange={handleChange}
placeholder="Че потерял?" placeholder="Search for movies, TV shows, games..."
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" 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"
/> />
<FiSearch className="absolute left-4 top-1/2 transform -translate-y-1/2 text-campfire-ash" size={18} />
{query && ( {query && (
<button <button
type="button"
onClick={() => setQuery('')} onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-campfire-ash hover:text-campfire-light" className="absolute right-16 top-1/2 transform -translate-y-1/2 text-campfire-ash hover:text-campfire-light"
> >
<FiX /> <FiX size={18} />
</button> </button>
)} )}
</div>
<button <button
onClick={onClose} type="submit"
className="px-4 py-2 text-campfire-light hover:text-campfire-amber transition-colors" 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"
> >
Отмена Search
</button> </button>
</div> </form>
{/* Search Results */} {/* Quick results dropdown */}
{(query.length >= 2 || results.length > 0) && ( {query.trim().length > 2 && searchResults.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="absolute z-10 mt-2 w-full bg-campfire-charcoal rounded-md shadow-lg max-h-96 overflow-y-auto">
<div className="container-custom mx-auto px-4 py-4"> <div className="py-2">
{isLoading ? ( {loading ? (
<div className="text-campfire-light text-center">Поиск...</div> <div className="px-4 py-2 text-campfire-ash">Loading...</div>
) : ( ) : (
<SearchResults results={results} onResultClick={handleResultClick} /> 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>
)} )}
</div> </div>
</div> </div>
)} )}
</div> </div>
); );
}; }
export default SearchBar; export default SearchBar;

View File

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

View File

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

View File

@ -3,26 +3,21 @@ import React, {
useContext, useContext,
useState, useState,
useEffect, useEffect,
useMemo,
} from "react"; } from "react";
import { import {
pb, // Import PocketBase instance for authStore listener supabase,
signUp as pbSignUp, signUp as supabaseSignUp,
signIn as pbSignIn, signIn as supabaseSignIn,
signOut as pbSignOut, signOut as supabaseSignOut,
getCurrentUser,
getUserProfile, getUserProfile,
requestPasswordReset as pbRequestPasswordReset, // Import the new function } from "../services/supabase";
} from "../services/pocketbaseService"; // Use the new service file
const AuthContext = createContext(); const AuthContext = createContext();
export const useAuth = () => { export const useAuth = () => {
// CORRECT: useContext is called at the top level of the useAuth hook function
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { 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"); throw new Error("useAuth must be used within an AuthProvider");
} }
return context; return context;
@ -31,241 +26,203 @@ export const useAuth = () => {
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null); const [currentUser, setCurrentUser] = useState(null);
const [userProfile, setUserProfile] = useState(null); const [userProfile, setUserProfile] = useState(null);
const [loading, setLoading] = useState(true); // Start in loading state const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); 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) => { const loadUserProfile = async (userId) => {
if (!userId) { if (!userId) {
console.log('AuthProvider: loadUserProfile: Нет userId для загрузки профиля'); console.log('AuthProvider: Нет userId для загрузки профиля');
return null; setLoading(false);
return;
} }
try { try {
console.log('AuthProvider: loadUserProfile: Загрузка профиля пользователя:', userId); console.log('AuthProvider: Загрузка профиля пользователя:', userId);
// In PocketBase, the auth model *is* the user record, so we don't need a separate profile fetch const profile = await getUserProfile(userId);
// 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) { } catch (error) {
console.error('AuthProvider: loadUserProfile: Ошибка загрузки профиля:', error); // Add error logging console.error('AuthProvider: Ошибка загрузки профиля:', error);
// Don't set global error here, just log setError(error.message);
return null; 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);
} }
}; };
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
console.log('AuthProvider: useEffect: Инициализация PocketBase authStore listener...'); // Add logging console.log('AuthProvider: Инициализация...');
// Use PocketBase's authStore.onChange listener const initialize = async () => {
// The 'true' argument runs the listener immediately on mount, try {
// which is perfect for initial auth state check. await checkSession();
const unsubscribe = pb.authStore.onChange(async (token, model) => { } catch (error) {
if (!mounted) { console.error('AuthProvider: Ошибка инициализации:', error);
console.log('AuthProvider: authStore.onChange: Component unmounted, skipping state update.'); // Add logging 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);
return; return;
} }
console.log('AuthProvider: authStore.onChange: Изменение состояния авторизации:', { token: !!token, model: !!model }); // Add logging if (event === 'SIGNED_IN') {
console.log('AuthProvider: Пользователь вошел в систему');
// setLoading(true); // Avoid setting loading here to prevent flicker during state changes setCurrentUser(session.user);
setError(null); // Clear previous errors await loadUserProfile(session.user.id);
} else if (event === 'SIGNED_OUT' || event === 'USER_DELETED') {
try { // Wrap state update logic in try-catch console.log('AuthProvider: Пользователь вышел из системы');
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); setCurrentUser(null);
setUserProfile(null); setUserProfile(null);
setError(null); // Clear error when user logs out setLoading(false);
} }
} 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 () => { return () => {
console.log('AuthProvider: useEffect cleanup: Отписка от изменений состояния авторизации'); // Add logging console.log('AuthProvider: Отписка от изменений состояния авторизации');
mounted = false; mounted = false;
unsubscribe(); // Unsubscribe from the listener subscription.unsubscribe();
}; };
}, []); // 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) => { const signIn = async (email, password) => {
try { try {
setLoading(true); // Set loading at the start of the async operation setLoading(true);
setError(null); setError(null);
console.log('AuthProvider: signIn: Попытка входа для', email); // Add logging const { data, error } = await supabase.auth.signInWithPassword({
const userRecord = await pbSignIn(email, password); // pbSignIn returns the record email,
console.log('AuthProvider: signIn: Вход успешен', userRecord); // Add logging password
// authStore.onChange listener will handle setting currentUser and userProfile });
return userRecord; if (error) throw error;
return data;
} catch (error) { } catch (error) {
console.error('AuthProvider: signIn: Ошибка входа:', error); // Add error logging
setError(error.message); setError(error.message);
throw error; throw error;
} finally { } finally {
// Loading is handled by authStore.onChange after state update setLoading(false);
// However, if authStore.onChange doesn't fire (e.g., invalid credentials),
// we need to ensure loading is set to false here.
// Let's add a small delay or check authStore state to avoid race conditions
// with the authStore.onChange listener. A simple finally block might conflict.
// A better approach is to rely solely on authStore.onChange for loading state
// after the initial check, but ensure errors are caught and displayed.
// For now, let's ensure error is set and the UI reacts to it.
// setLoading(false); // Removed to rely on authStore.onChange
console.log('AuthProvider: signIn: Operation finished.'); // Add logging
} }
}; };
const signUp = async (email, password, username) => { const signUp = async (email, password) => {
try { try {
setLoading(true); // Set loading at the start of the async operation setLoading(true);
setError(null); setError(null);
console.log('AuthProvider: signUp: Попытка регистрации для', email); // Add logging const { data, error } = await supabase.auth.signUp({
const { user, profile } = await pbSignUp(email, password, username); // pbSignUp returns user and profile email,
console.log('AuthProvider: signUp: Регистрация успешна', { user, profile }); // Add logging password
// authStore.onChange listener will handle setting currentUser and userProfile });
return { user, profile }; if (error) throw error;
return data;
} catch (error) { } catch (error) {
console.error('AuthProvider: signUp: Ошибка регистрации:', error); // Add error logging
setError(error.message); setError(error.message);
throw error; throw error;
} finally { } finally {
// Loading is handled by authStore.onChange after state update setLoading(false);
// setLoading(false); // Removed to rely on authStore.onChange
console.log('AuthProvider: signUp: Operation finished.'); // Add logging
} }
}; };
const signOut = async () => { 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 { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
console.log('AuthProvider: requestPasswordReset: Попытка сброса пароля для', email); // Add logging const { error } = await supabase.auth.signOut();
await pbRequestPasswordReset(email); if (error) throw error;
console.log('AuthProvider: requestPasswordReset: Запрос на сброс пароля отправлен'); // Add logging
return true; // Indicate success
} catch (error) { } catch (error) {
console.error('AuthProvider: requestPasswordReset: Ошибка при запросе сброса пароля:', error); // Add error logging
setError(error.message); setError(error.message);
throw error; throw error;
} finally { } finally {
setLoading(false); setLoading(false);
console.log('AuthProvider: requestPasswordReset: Operation finished.'); // Add logging
} }
}; };
const value = {
// Use useMemo for context value user: currentUser,
const value = useMemo(() => ({ userProfile,
user: currentUser, // This is the PocketBase user record
userProfile, // This is the same as user in PocketBase context
loading, loading,
error, error,
signIn, // Include signIn in the context value signIn,
signUp, 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: Rendering with value:', { user: !!value.user, userProfile: !!value.userProfile, loading: value.loading, error: !!value.error, isInitialized: value.isInitialized }); // Add logging console.log('AuthProvider: Текущее состояние:', value);
// Render loading or error state until initialized if (loading && !currentUser) {
// Removed the error display block here to rely on pages displaying errors from context console.log('AuthProvider: Отображение состояния загрузки');
// We still show a loading spinner if not initialized or loading
if (loading || !isInitialized) {
console.log('AuthProvider: Рендеринг состояния загрузки/инициализации...'); // Add logging
return ( return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark"> <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 className="text-campfire-amber">Загрузка...</div>
</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>
);
}; };

View File

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

View File

@ -1,5 +1,6 @@
import React, { createContext, useContext, useState } from 'react'; import React, { createContext, useContext, useState } from 'react';
import { createMedia, validateMediaData, formatMediaData } from '../services/pocketbaseService'; // Import from pocketbaseService, including validation/formatting import { searchMedia, getMediaDetails, validateMediaData, formatMediaData } from '../services/mediaService';
import { createMedia } from '../services/supabase';
const MediaContext = createContext(); const MediaContext = createContext();
@ -12,60 +13,72 @@ export const useMedia = () => {
}; };
export const MediaProvider = ({ children }) => { export const MediaProvider = ({ children }) => {
// Removed searchResults and selectedMedia states as search/details logic is likely handled elsewhere now const [searchResults, setSearchResults] = useState([]);
const [selectedMedia, setSelectedMedia] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Removed handleSearch and handleSelectMedia as they likely relied on the removed mediaService functionality const handleSearch = async (query, type = 'movie') => {
// If external search/details is needed, this logic will need to be reimplemented using a different service/API. 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);
}
};
const handleCreateMedia = async (mediaData) => { const handleCreateMedia = async (mediaData) => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
console.log('MediaContext: Попытка создания медиа:', mediaData); // Валидация данных
// Валидация данных (теперь из pocketbaseService)
const errors = validateMediaData(mediaData); const errors = validateMediaData(mediaData);
if (errors.length > 0) { if (errors.length > 0) {
console.error('MediaContext: Ошибки валидации:', errors); throw new Error(errors.join('\n'));
throw new Error('Ошибки валидации:\n' + errors.join('\n'));
} }
console.log('MediaContext: Валидация данных успешна.');
// Форматирование данных
// Форматирование данных (теперь из pocketbaseService)
const formattedData = formatMediaData(mediaData); const formattedData = formatMediaData(mediaData);
console.log('MediaContext: Данные отформатированы для PocketBase:', formattedData);
// Создание медиа
// Создание медиа через PocketBase
const newMedia = await createMedia(formattedData); const newMedia = await createMedia(formattedData);
console.log('MediaContext: Медиа успешно создано:', newMedia);
return newMedia; return newMedia;
} catch (error) { } catch (error) {
console.error('MediaContext: Ошибка при создании медиа:', error); console.error('Error creating media:', 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 || 'Ошибка при создании медиа'); setError(error.message || 'Ошибка при создании медиа');
} throw error;
throw error; // Re-throw to allow calling component to handle
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const value = { const value = {
// Removed searchResults, selectedMedia searchResults,
selectedMedia,
loading, loading,
error, error,
// Removed handleSearch, handleSelectMedia handleSearch,
handleSelectMedia,
handleCreateMedia handleCreateMedia
}; };

View File

@ -1,36 +0,0 @@
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;
};

View File

@ -2,271 +2,91 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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 { @layer base {
html { html {
@apply antialiased; @apply scroll-smooth;
} }
body { body {
@apply bg-campfire-dark text-campfire-light; @apply bg-campfire-dark text-campfire-light font-sans m-0 min-h-screen;
user-select: none; font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
@apply font-bold; @apply font-bold leading-tight mb-4;
}
} }
/* Custom scrollbar styles */ h1 {
@layer utilities { @apply text-3xl md:text-4xl;
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
width: 0;
} }
.overflow-hidden { h2 {
overflow-x: hidden; @apply text-2xl md:text-3xl;
} }
/* Hide scrollbar for IE, Edge and Firefox */ h3 {
body { @apply text-xl md:text-2xl;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
} }
/* Keep custom thin scrollbar for specific elements if needed */ a {
.scrollbar-thin { @apply text-campfire-amber transition-colors duration-300;
scrollbar-width: thin;
scrollbar-color: var(--campfire-amber) var(--campfire-charcoal);
} }
.scrollbar-thin::-webkit-scrollbar { a:hover {
width: 8px; @apply text-campfire-ember;
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 { @layer components {
.container-custom { .container-custom {
@apply container mx-auto px-4 sm:px-6 lg:px-8; @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
} }
.btn { .btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200; @apply px-4 py-2 rounded-md font-medium transition-all duration-300 focus:outline-none;
} }
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-campfire-dark bg-campfire-secondary hover:bg-campfire-secondary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-campfire-secondary transition-colors duration-200; @apply btn bg-campfire-amber text-campfire-dark hover:bg-campfire-ember;
} }
.btn-secondary { .btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-campfire-light bg-campfire-dark hover:bg-campfire-dark/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-campfire-dark transition-colors duration-200; @apply btn border border-campfire-ash text-campfire-ash hover:bg-campfire-charcoal;
}
.btn-outline {
@apply inline-flex items-center justify-center px-6 py-3 border border-campfire-light text-base font-medium rounded-md shadow-sm text-campfire-light hover:bg-campfire-light/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-campfire-light transition-colors duration-200;
}
.input {
@apply block w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-dark rounded-md focus:outline-none focus:ring-2 focus:ring-campfire-secondary focus:border-campfire-secondary;
}
.label {
@apply block text-sm font-medium text-campfire-light/60 mb-1;
} }
.card { .card {
@apply bg-campfire-darker rounded-lg shadow-lg border border-campfire-dark/20 overflow-hidden; @apply bg-campfire-charcoal rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:scale-[1.02];
} }
.card-header { .input {
@apply px-6 py-4 border-b border-campfire-dark/20; @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;
}
} }
.card-body { /* Custom scrollbar */
@apply px-6 py-4; ::-webkit-scrollbar {
width: 8px;
height: 8px;
} }
.card-footer { ::-webkit-scrollbar-track {
@apply px-6 py-4 border-t border-campfire-dark/20; @apply bg-campfire-dark;
} }
.input-field { ::-webkit-scrollbar-thumb {
@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; @apply bg-campfire-ash rounded-full;
} }
.textarea-field { ::-webkit-scrollbar-thumb:hover {
@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; @apply bg-campfire-amber;
}
/* 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;
}
} }

View File

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

View File

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

View File

@ -1,567 +0,0 @@
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;

View File

@ -1,331 +0,0 @@
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;

View File

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

View File

@ -1,39 +0,0 @@
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;

View File

@ -1,466 +1,131 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useState, useEffect } from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { getLatestReviews, listMedia, listUsersRankedByReviews, getFileUrl, getFeaturedMedia } from '../services/pocketbaseService'; import { useMedia } from "../contexts/MediaContext";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from "../contexts/AuthContext";
import LatestReviewsMarquee from '../components/reviews/LatestReviewsMarquee'; import { listMedia } from "../services/supabase";
import { FaFire, FaUsers, FaCrown, FaMedal } from 'react-icons/fa'; import { mediaTypes } from "../services/mediaService";
import StatsSection from '../components/home/StatsSection'; import { FiTrendingUp, FiCalendar, FiAward } from "react-icons/fi";
import GridMotion from '../components/reactbits/Backgrounds/GridMotion/GridMotion.jsx'; import MediaCarousel from "../components/media/MediaCarousel";
import { useProfileActions } from '../contexts/ProfileActionsContext'; import { getImageUrl } from "../services/tmdbApi";
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 HomePage = () => {
const { userProfile } = useAuth(); const [media, setMedia] = useState([]);
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 [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [stats, setStats] = useState({ mediaCount: 0, reviewsCount: 0 }); const { user } = useAuth();
const [posters, setPosters] = useState([]);
const [featuredMedia, setFeaturedMedia] = useState([]);
const [isDataReady, setIsDataReady] = useState(false);
const containerVariants = { useEffect(() => {
hidden: { opacity: 0 }, const loadMedia = async () => {
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.5
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: "easeOut"
}
}
};
const loadingVariants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: {
duration: 0.5
}
},
exit: {
opacity: 0,
transition: {
duration: 0.5
}
}
};
const fetchData = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const { data, error } = await listMedia();
const [ if (error) {
popularMediaData, throw new Error(error);
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);
setMedia(data || []);
} catch (err) { } catch (err) {
console.error('[HomePage] Ошибка загрузки данных:', err); console.error("Error loading media:", err);
setError('Не удалось загрузить данные главной страницы.'); setError("Не удалось загрузить контент");
} finally {
setLoading(false); 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); loadMedia();
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 (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>
);
}
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center"> <div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center"> <div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error} {error}
</div> </div>
</div> </div>
</div>
); );
} }
// Helper function to get pedestal styles based on rank
const getPedestalStyles = (rank) => {
switch (rank) {
case 1:
return {
container: 'bg-gradient-to-br from-yellow-400 to-yellow-600 text-campfire-dark border-yellow-700',
avatarBorder: 'border-yellow-700',
iconColor: 'text-yellow-800',
crown: <FaCrown className="text-yellow-800 text-3xl absolute -top-4 right-1/2 translate-x-1/2" />
};
case 2:
return {
container: 'bg-gradient-to-br from-gray-400 to-gray-600 text-campfire-dark border-gray-700',
avatarBorder: 'border-gray-700',
iconColor: 'text-gray-800',
medal: <FaMedal className="text-gray-800 text-2xl absolute -top-3 right-1/2 translate-x-1/2" />
};
case 3:
return {
container: 'bg-gradient-to-br from-amber-700 to-amber-900 text-campfire-dark border-amber-950',
avatarBorder: 'border-amber-950',
iconColor: 'text-amber-950',
medal: <FaMedal className="text-amber-950 text-2xl absolute -top-3 right-1/2 translate-x-1/2" />
};
default:
return {
container: 'bg-campfire-charcoal text-campfire-light border-campfire-ash/20',
avatarBorder: 'border-campfire-dark',
iconColor: 'text-campfire-amber'
};
}
};
const renderTopReviewers = () => {
return ( return (
<div className="space-y-4"> <div className="min-h-screen bg-campfire-dark pt-20">
{topUsers.map((user) => ( <div className="container-custom py-12">
<div key={user.id} className="flex items-center space-x-4 bg-campfire-dark/50 p-4 rounded-lg"> <div className="flex justify-between items-center mb-8">
<Link to={`/profile/${user.username}`} className="flex-shrink-0"> <h1 className="text-3xl font-bold text-campfire-light">
<img Добро пожаловать в CampFire
src={getFileUrl(user, 'profile_picture', { thumb: '100x100' }) || '/default-avatar.png'} </h1>
alt={user.username} {user?.role === "admin" && (
className="w-12 h-12 rounded-full object-cover" <Link to="/admin/media" className="btn-secondary">
/> Управление контентом
</Link> </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>
);
};
console.log(posters) {media.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
return ( {media.map((item) => (
<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 <Link
to={`/media/${mediaItem.path}`} key={`${item.id}-${item.type}`}
className="block group" to={`/media/${item.id}`}
className="group"
> >
<TiltedCard <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">
imageSrc={mediaItem.poster ? getFileUrl(mediaItem, 'poster', { thumb: '300x450' }) : 'https://via.placeholder.com/300x450'} <div className="aspect-[2/3] relative">
altText={mediaItem.title} <img
captionText={mediaItem.title} src={item.poster_url}
containerHeight="360px" alt={item.title}
containerWidth="100%" className="w-full h-full object-cover"
imageHeight="360px"
imageWidth="240px"
scaleOnHover={1.05}
rotateAmplitude={10}
showMobileWarning={false}
showTooltip={false}
displayOverlayContent={false}
rating={mediaItem.average_rating}
releaseDate={mediaItem.release_date}
/> />
<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> </Link>
</motion.div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-campfire-ash text-center py-8">Нет данных о популярном медиа.</p> <div className="text-center py-12">
)} <p className="text-campfire-ash text-lg">
</motion.div> Пока нет доступного контента
</p>
{/* Top Users Section */} {user?.role === "admin" && (
<motion.div variants={itemVariants}> <Link to="/admin/media" className="inline-block mt-4 btn-primary">
<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> </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> </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> </div>
) : (
<p className="text-campfire-ash text-center py-8">Нет данных о последнем добавленном медиа.</p>
)}
</motion.div>
</div> </div>
</motion.div>
</div>
<motion.div variants={itemVariants}>
<FeaturedMedia media={featuredMedia} />
</motion.div>
</motion.div>
)}
</AnimatePresence>
); );
}; };

View File

@ -1,101 +1,92 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; // Import useAuth import { useAuth } from '../contexts/AuthContext';
const LoginPage = () => { const LoginPage = () => {
const [login, setLogin] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const navigate = useNavigate(); 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) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(null);
setLoading(true);
try { try {
await signIn(login, password); setLoading(true);
setError(null);
await signIn(email, password);
navigate('/'); navigate('/');
} catch (err) { } catch (err) {
console.error('Login error:', err); console.error('Login error:', err);
setError(err.message); setError(err.message || 'Ошибка входа');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Render loading state based on AuthContext loading
// Removed the check here to allow the form to render even if auth is loading,
// relying on the AuthProvider's own loading state handling.
// This might help diagnose if the component itself is not rendering.
// if (authLoading || !isInitialized) {
// console.log('LoginPage: Rendering initial auth loading state.');
// return (
// <div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark text-campfire-light">
// Загрузка авторизации...
// </div>
// );
// }
console.log('LoginPage: Rendering login form.'); // Log before rendering form
return ( return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center"> <div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="w-full max-w-md p-8 bg-campfire-charcoal rounded-lg shadow-lg"> <div className="container-custom max-w-md py-12">
<h1 className="text-2xl font-bold text-campfire-light mb-6 text-center">Вход</h1> <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>
{error && ( {error && (
<div className="mb-4 p-3 bg-status-error/20 text-status-error rounded-lg text-center"> <div className="bg-status-error/20 text-status-error p-3 rounded-md mb-6 text-sm">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label htmlFor="login" className="block text-sm font-medium text-campfire-light mb-1"> <label
Логин htmlFor="email"
className="block text-sm font-medium text-campfire-light mb-1"
>
Email
</label> </label>
<input <input
type="text" type="email"
id="login" id="email"
value={login} name="email"
onChange={(e) => setLogin(e.target.value)} value={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" onChange={(e) => setEmail(e.target.value)}
placeholder="Введите логин" className="input w-full"
placeholder="your@email.com"
required required
autoComplete="username" autoComplete="email"
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-campfire-light mb-1"> <div className="flex justify-between items-center mb-1">
<label
htmlFor="password"
className="block text-sm font-medium text-campfire-light"
>
Пароль Пароль
</label> </label>
<Link
to="/forgot-password"
className="text-xs text-campfire-amber hover:text-campfire-ember transition-colors"
>
Забыли пароль?
</Link>
</div>
<input <input
type="password" type="password"
id="password" id="password"
name="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors" className="input w-full"
placeholder="••••••••" placeholder="••••••••"
required required
autoComplete="current-password" autoComplete="current-password"
@ -105,7 +96,7 @@ const LoginPage = () => {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className={`btn-primary w-full ${loading ? "opacity-80 cursor-not-allowed" : ""} transition-colors duration-200`} className={`btn-primary w-full ${loading ? "opacity-80" : ""}`}
> >
{loading ? ( {loading ? (
<span className="inline-flex items-center"> <span className="inline-flex items-center">
@ -129,7 +120,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" 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> ></path>
</svg> </svg>
Входим... Вход...
</span> </span>
) : ( ) : (
"Войти" "Войти"
@ -138,9 +129,9 @@ const LoginPage = () => {
</form> </form>
<div className="mt-6 text-center text-sm text-campfire-ash"> <div className="mt-6 text-center text-sm text-campfire-ash">
Еще нет аккаунта?{" "} Нет аккаунта?{" "}
<Link <Link
to="/auth/register" to="/register"
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors" className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors"
> >
Зарегистрироваться Зарегистрироваться
@ -148,6 +139,7 @@ const LoginPage = () => {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
}; };

View File

@ -1,585 +0,0 @@
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

107
src/pages/MediaPage.jsx Normal file
View File

@ -0,0 +1,107 @@
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;

View File

@ -1,40 +1,24 @@
import React from 'react'; import { Link } from "react-router-dom";
import { Link } from 'react-router-dom'; import { FiHome } from "react-icons/fi";
import { FaHome } from 'react-icons/fa';
import FuzzyText from '../components/reactbits/TextAnimations/FuzzyText/FuzzyText';
import notFoundImage from '../assets/404.webp';
const NotFoundPage = () => { function NotFoundPage() {
return ( return (
<div className="min-h-screen bg-campfire-dark flex flex-col items-center justify-center p-4"> <div className="pt-20 flex items-center justify-center min-h-screen">
<div className="text-center"> <div className="container-custom max-w-2xl text-center py-12">
<img <h1 className="text-6xl font-bold mb-6 text-campfire-amber">404</h1>
src={notFoundImage} <h2 className="text-3xl font-bold mb-4">
alt="404" Оказавшись в лимбе, вы не нашли подходящую страницу
className="w-64 h-64 mb-8 mx-auto" </h2>
/> <p className="text-campfire-ash mb-8">
<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> </p>
<Link <Link to="/" className="btn-primary inline-flex items-center">
to="/" <FiHome className="mr-2" /> Вернуться
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> </Link>
</div> </div>
</div> </div>
); );
}; }
export default NotFoundPage; export default NotFoundPage;

File diff suppressed because it is too large Load Diff

View File

@ -1,433 +0,0 @@
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;

View File

@ -1,210 +0,0 @@
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;

View File

@ -1,43 +1,23 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
function RegisterPage() { function RegisterPage() {
const [login, setLogin] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { user, userProfile, loading: authLoading, signUp, isInitialized } = useAuth(); const { signUp } = useAuth();
const navigate = useNavigate(); 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) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!login || !password || !confirmPassword) { if (!email || !username || !password || !confirmPassword) {
setError("Заполни обязательные поля"); setError("Заполни все поля");
return; return;
} }
@ -51,191 +31,126 @@ function RegisterPage() {
return; return;
} }
// Проверка на допустимые символы в логине
if (!/^[a-zA-Z0-9_]+$/.test(login)) {
setError("Логин может содержать только латинские буквы, цифры и знак подчеркивания");
return;
}
// Проверка длины логина
if (login.length < 3 || login.length > 20) {
setError("Логин должен быть от 3 до 20 символов");
return;
}
try { try {
setError(""); setError("");
setLoading(true); setLoading(true);
console.log('RegisterPage handleSubmit: Attempting sign up...'); await signUp(email, password, username);
await signUp(login, password, email || null); navigate("/");
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) { } catch (err) {
console.error("Registration error:", err); setError(
// More specific error handling for PocketBase unique constraints "Дружище, ты уже существуешь, либо кто-то другой зарегистрирован с такой же почтой."
if (err.response && err.response.data) { );
const errorData = err.response.data; console.error(err);
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 { } finally {
setLoading(false); 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 ( return (
<div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark text-campfire-light"> <div className="pt-20 min-h-screen flex items-center justify-center">
<div className="container-custom max-w-md py-12"> <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="bg-campfire-charcoal rounded-lg shadow-lg p-8">
<div className="text-center mb-8"> <h1 className="text-3xl font-bold mb-6 text-center">
<h1 className="text-3xl font-bold text-campfire-light mb-2"> Присоединиться к CampFire Critics
Присоединиться к CampFire мнеие
</h1> </h1>
<p className="text-campfire-ash"> <p className="text-campfire-ash mb-8 text-center">
Создай свою учетную запись CampFire, чтобы оценивать и рецензировать Создай свою учетную запись CampFire, чтобы оценивать и рецензировать
все что движется. все что движется.
</p> </p>
</div>
{error && ( {error && (
<div className="bg-status-error/20 text-status-error p-4 rounded-md mb-6 border border-status-error/30"> <div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-md mb-6">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit}>
<div> <div className="mb-6">
<label htmlFor="login" className="block text-sm font-medium text-campfire-light mb-1"> <label htmlFor="email" className="block text-campfire-light mb-2">
Логин * Email
</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> </label>
<input <input
type="email" type="email"
id="email" id="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors" className="input w-full"
placeholder="your@campfiregg.ru" placeholder="your@campfiregg.ru"
autoComplete="email" required
/> />
</div> </div>
<div> <div className="mb-6">
<label htmlFor="password" className="block text-sm font-medium text-campfire-light mb-1"> <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"
>
Пароль
</label> </label>
<input <input
type="password" type="password"
id="password" id="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors" className="input w-full"
placeholder="••••••••" placeholder="••••••••"
required required
autoComplete="new-password"
/> />
<p className="text-xs text-campfire-ash mt-1"> <p className="text-xs text-campfire-ash mt-1">
Не меньше 6 знаков Не меньше 6 знаков
</p> </p>
</div> </div>
<div> <div className="mb-8">
<label htmlFor="confirm-password" className="block text-sm font-medium text-campfire-light mb-1"> <label
Повторите пароль * htmlFor="confirm-password"
className="block text-campfire-light mb-2"
>
Пароль пароль
</label> </label>
<input <input
type="password" type="password"
id="confirm-password" id="confirm-password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors" className="input w-full"
placeholder="••••••••" placeholder="••••••••"
required required
autoComplete="new-password"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className={`btn-primary w-full ${loading ? "opacity-80 cursor-not-allowed" : ""} transition-colors duration-200`} className="btn-primary w-full"
> >
{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> </button>
</form> </form>
<div className="mt-6 text-center text-sm text-campfire-ash"> <div className="mt-6 text-center">
Уже в строю?{" "} <span className="text-campfire-ash">Уже в строю?</span>{" "}
<Link <Link
to="/auth/login" to="/login"
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors" className="text-campfire-amber hover:text-campfire-ember"
> >
Войти Войти
</Link> </Link>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,65 +0,0 @@
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;

View File

@ -1,70 +0,0 @@
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;

View File

@ -1,82 +0,0 @@
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
src/services/firebase.js Normal file
View File

@ -0,0 +1 @@
// This file can be deleted as it's no longer needed

View File

@ -0,0 +1,208 @@
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
};
};

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