Compare commits

...

2 Commits

Author SHA1 Message Date
Degradin
7ba6562c74 8.2 update 2025-09-05 16:12:32 +03:00
Degradin
881e6da923 Адаптирована мобильная версия 2025-05-25 12:43:27 +03:00
104 changed files with 13318 additions and 2363 deletions

51
Caddyfile Normal file
View File

@ -0,0 +1,51 @@
auth.campfiregg.ru {
root * /data/www-auth
encode gzip
try_files {path} /index.html
file_server
# Настройки безопасности
header {
# Включаем HSTS
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Запрещаем встраивание в фреймы
X-Frame-Options "SAMEORIGIN"
# Включаем XSS защиту
X-XSS-Protection "1; mode=block"
# Запрещаем MIME-sniffing
X-Content-Type-Options "nosniff"
# Настройки CSP
Content-Security-Policy "default-src 'self' https://api.campfiregg.ru; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.campfiregg.ru"
}
# Настройки CORS
@cors_preflight method OPTIONS
handle @cors_preflight {
header Access-Control-Allow-Origin "https://mneie.campfiregg.ru https://staff.campfiregg.ru https://game.campfiregg.ru"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"
header Access-Control-Max-Age "3600"
respond 204
}
handle {
header Access-Control-Allow-Origin "https://mneie.campfiregg.ru https://staff.campfiregg.ru https://game.campfiregg.ru"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"
}
}
# Остальные настройки для других доменов
:80 {
root * /data/www
try_files {path} /index.html
file_server browse
php_fastcgi unix//run/php/php-fpm.sock
}
:443 {
root * /data/www
try_files {path} /index.html
file_server browse
php_fastcgi unix//run/php/php-fpm.sock
}

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "."
},
{
"path": "../campfire-auth"
}
],
"settings": {}
}

1934
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,27 +7,37 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"server": "node server.js",
"dev:full": "concurrently \"npm run dev\" \"npm run server\""
},
"dependencies": {
"@react-three/fiber": "^8.18.0",
"@react-three/postprocessing": "^2.19.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"chart.js": "^4.4.3",
"cors": "^2.8.5",
"dompurify": "^3.2.5",
"express": "^5.1.0",
"framer-motion": "^11.18.2",
"gsap": "^3.13.0",
"node-telegram-bot-api": "^0.66.0",
"pocketbase": "^0.21.3",
"postprocessing": "^6.37.3",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-chartjs-2": "^5.2.0",
"react-datepicker": "^8.3.0",
"react-dom": "^18.2.0",
"react-fast-marquee": "^1.6.4",
"react-fast-marquee": "^1.6.5",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0",
"react-quill": "^2.0.0",
"react-router-dom": "^6.23.1",
"react-select": "^5.10.1",
"react-toastify": "^10.0.5",
"react-toastify": "^11.0.5",
"three": "^0.167.1",
"uuid": "^11.1.0"
},
"devDependencies": {

View File

@ -1 +0,0 @@

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 B

After

Width:  |  Height:  |  Size: 286 KiB

176
server.js Normal file
View File

@ -0,0 +1,176 @@
import express from 'express';
import cors from 'cors';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import generateReviewRouter from './src/api/generate-review.js';
import TelegramBot from 'node-telegram-bot-api';
import PocketBase from 'pocketbase';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const port = process.env.PORT || 3000;
// Инициализация Telegram бота
const bot = new TelegramBot('8057681898:AAE0BCbcg6M0BgREaOeXsjK7yXBHVpOIs9c', { polling: false });
// Инициализация PocketBase
const pb = new PocketBase('https://pocketbase.campfiregg.ru');
// Middleware
app.use(cors());
app.use(express.json());
// API routes
app.use('/api', generateReviewRouter);
// Telegram авторизация
app.post('/api/auth/telegram', async (req, res) => {
try {
const { id, first_name, username, photo_url, auth_date, hash } = req.body;
if (!id) {
return res.status(400).json({
success: false,
error: 'Отсутствует ID пользователя Telegram'
});
}
console.log('Получены данные от Telegram:', { id, first_name, username });
// Очищаем и валидируем данные
const cleanUsername = (username || `user_${id}`).replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
const cleanFirstName = (first_name || '').replace(/[<>]/g, '');
// Проверяем, существует ли пользователь с таким Telegram ID
const users = await pb.collection('users').getList(1, 1, {
filter: `telegram_id = ${id}`
});
let user;
if (users.items.length > 0) {
// Если пользователь существует, используем его
user = users.items[0];
console.log('Найден существующий пользователь:', user.id);
} else {
// Генерируем случайный пароль для пользователя
const randomPassword = Math.random().toString(36).slice(-8);
// Если пользователь не существует, создаем нового
try {
const userData = {
username: cleanUsername,
login: cleanUsername,
telegram_id: id,
telegram_username: username,
telegram_first_name: cleanFirstName,
telegram_photo_url: photo_url,
email: `${cleanUsername}@telegram.user`,
password: randomPassword,
passwordConfirm: randomPassword,
verified: true
};
console.log('Создаем пользователя с данными:', userData);
user = await pb.collection('users').create(userData);
console.log('Создан новый пользователь:', user.id);
} catch (createError) {
console.error('Ошибка при создании пользователя:', createError);
return res.status(500).json({
success: false,
error: 'Ошибка при создании пользователя: ' + (createError.data?.message || createError.message)
});
}
}
// Генерируем JWT токен
const token = `telegram_${id}_${Date.now()}`;
res.json({
success: true,
token,
user: {
id: user.id,
username: user.username,
login: user.login,
telegram_id: id,
telegram_username: username,
telegram_first_name: first_name,
telegram_photo_url: photo_url
}
});
} catch (error) {
console.error('Ошибка авторизации через Telegram:', error);
res.status(500).json({
success: false,
error: 'Ошибка авторизации: ' + (error.data?.message || error.message)
});
}
});
// Привязка Telegram к существующему профилю
app.post('/api/auth/telegram/link', async (req, res) => {
try {
const { id, first_name, username, photo_url, auth_date, hash, userId } = req.body;
if (!userId) {
return res.status(400).json({
success: false,
error: 'Не указан ID пользователя'
});
}
// Проверяем, не привязан ли уже этот Telegram ID к другому аккаунту
const existingUsers = await pb.collection('users').getList(1, 1, {
filter: `telegram_id = ${id}`
});
if (existingUsers.items.length > 0) {
return res.status(400).json({
success: false,
error: 'Этот Telegram аккаунт уже привязан к другому профилю'
});
}
// Обновляем профиль пользователя
const user = await pb.collection('users').update(userId, {
telegram_id: id,
telegram_username: username,
telegram_first_name: first_name,
telegram_photo_url: photo_url
});
res.json({
success: true,
user: {
id: user.id,
username: user.username,
telegram_id: id,
telegram_username: username,
telegram_first_name: first_name,
telegram_photo_url: photo_url
}
});
} catch (error) {
console.error('Ошибка привязки Telegram:', error);
res.status(500).json({
success: false,
error: 'Ошибка привязки Telegram'
});
}
});
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(join(__dirname, 'dist')));
app.get('*', (req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'));
});
}
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

View File

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

View File

@ -0,0 +1,95 @@
import { Router } from 'express';
const router = Router();
// Функция для задержки
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
router.post('/generate-review', async (req, res) => {
try {
const { prompt, model, temperature, max_tokens } = req.body;
const headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer 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',
'HTTP-Referer': 'http://localhost:5173',
'X-Title': 'Campfire Critics'
};
console.log('Request headers:', headers);
// Максимальное количество попыток
const maxRetries = 3;
let retryCount = 0;
let lastError = null;
while (retryCount < maxRetries) {
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt
}
]
}
],
temperature,
max_tokens
})
});
if (response.status === 429) {
console.log(`Rate limit hit, attempt ${retryCount + 1} of ${maxRetries}`);
// Увеличиваем время ожидания с каждой попыткой
await delay(Math.pow(2, retryCount) * 1000);
retryCount++;
continue;
}
if (!response.ok) {
const errorData = await response.json().catch(() => null);
console.error('OpenRouter API Error:', errorData);
console.error('Response status:', response.status);
console.error('Response headers:', response.headers);
return res.status(response.status).json({
error: {
message: errorData?.error?.message || 'Ошибка при генерации рецензии',
code: response.status
}
});
}
const data = await response.json();
return res.json(data);
} catch (error) {
lastError = error;
console.error(`Attempt ${retryCount + 1} failed:`, error);
retryCount++;
if (retryCount < maxRetries) {
await delay(Math.pow(2, retryCount) * 1000);
}
}
}
// Если все попытки исчерпаны
throw lastError || new Error('Превышен лимит запросов к API');
} catch (error) {
console.error('Error in generate-review endpoint:', error);
res.status(500).json({
error: {
message: error.message || 'Внутренняя ошибка сервера',
code: 500
}
});
}
});
export default router;

BIN
src/assets/cat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

View File

@ -64,7 +64,7 @@ const LatestReviewsMarquee = () => {
const username = user.username || 'Анонимный пользователь';
const avatarUrl = user.profile_picture ? getFileUrl(user, 'profile_picture', { thumb: '40x40' }) : null;
const userProfileLink = user.username ? `/profile/${user.username}` : '#';
const userProfileLink = user.login ? `/profile/${user.login}` : '#';
const mediaTitle = media.title || 'Неизвестное произведение';
const mediaLink = media.path ? `/media/${media.path}` : '#';

View File

@ -0,0 +1,100 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const TelegramAuth = () => {
const navigate = useNavigate();
const location = useLocation();
const scriptRef = useRef(null);
const containerRef = useRef(null);
const { user, userProfile } = useAuth();
// Определяем, является ли это привязкой к существующему профилю
const isLinking = location.pathname === '/profile/telegram-link';
useEffect(() => {
// Создаем скрипт для Telegram Login Widget
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.setAttribute('data-telegram-login', 'CampFireAuthBot');
script.setAttribute('data-size', 'large');
script.setAttribute('data-radius', '8');
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-userpic', 'false');
script.setAttribute('data-onauth', 'onTelegramAuth(user)');
script.async = true;
scriptRef.current = script;
// Добавляем функцию обратного вызова
window.onTelegramAuth = async (user) => {
try {
const endpoint = isLinking ? '/api/auth/telegram/link' : '/api/auth/telegram';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...user,
userId: isLinking ? userProfile?.id : undefined
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Ошибка авторизации');
}
if (data.success) {
if (isLinking) {
// После привязки возвращаемся на страницу профиля
navigate(`/profile/${userProfile.login}`);
} else {
// При авторизации сохраняем токен и перенаправляем на главную
localStorage.setItem('token', data.token);
// Используем setTimeout для гарантии сохранения токена перед редиректом
setTimeout(() => {
window.location.href = '/';
}, 100);
}
} else {
throw new Error(data.error || 'Ошибка авторизации');
}
} catch (error) {
console.error('Ошибка авторизации:', error);
alert(error.message || 'Произошла ошибка при авторизации. Пожалуйста, попробуйте снова.');
}
};
// Добавляем скрипт на страницу
if (containerRef.current) {
containerRef.current.appendChild(script);
}
return () => {
// Удаляем скрипт при размонтировании компонента
if (containerRef.current && scriptRef.current) {
try {
containerRef.current.removeChild(scriptRef.current);
} catch (error) {
console.error('Ошибка при удалении скрипта:', error);
}
}
};
}, [navigate, isLinking, userProfile]);
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="w-full max-w-md p-8 bg-campfire-charcoal rounded-lg shadow-lg">
<h2 className="text-2xl font-bold mb-6 text-center text-campfire-light">
{isLinking ? 'Привязка Telegram' : 'Вход через Telegram'}
</h2>
<div ref={containerRef} className="flex justify-center"></div>
</div>
</div>
);
};
export default TelegramAuth;

View File

@ -0,0 +1,238 @@
import React, { useState } from 'react';
import { FaRobot, FaSpinner, FaMagic } from 'react-icons/fa';
const AICreateModal = ({ onClose, onImport }) => {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
// Формируем промпт для AI
const prompt = `Создай JSON объект для медиа контента на основе запроса: "${query}".
Пожалуйста, верни только валидный JSON без дополнительного текста, который содержит следующие поля:
{
"title": "Название на русском языке",
"path": "url-slug-на-латинице",
"type": "movie|tv|game|anime",
"overview": "Описание на русском языке",
"release_date": "YYYY-MM-DD",
"progress_type": "hours|watched|completed",
"characteristics": {
"story": "Сюжет",
"acting": "Актерская игра",
"visuals": "Визуал",
"sound": "Звук",
"atmosphere": "Атмосфера",
"pacing": "Темп"
}
}
Тип должен соответствовать запросу:
- movie - для фильмов
- tv - для сериалов
- game - для игр
- anime - для аниме
progress_type должен соответствовать типу:
- hours - для игр
- watched - для фильмов/сериалов/аниме
- completed - для сюжетных игр
Используй веб-поиск для получения актуальной информации.`;
// Отправляем запрос к GPTunnel API
console.log('Отправляем запрос к GPTunnel с API ключом:', import.meta.env.VITE_GPTUNNEL_API ? '***' + import.meta.env.VITE_GPTUNNEL_API.slice(-4) : 'НЕ УСТАНОВЛЕН');
const response = await fetch('https://gptunnel.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${import.meta.env.VITE_GPTUNNEL_API}`
},
body: JSON.stringify({
model: 'gpt-5-mini',
messages: [
{
role: 'user',
content: prompt
}
],
tools: [
{
type: 'web_search'
}
],
tool_choice: 'auto'
})
});
if (!response.ok) {
console.error('GPTunnel API ответил с ошибкой:', response.status, response.statusText);
if (response.status === 402) {
throw new Error('Ошибка оплаты GPTunnel: проверьте баланс или подписку');
} else if (response.status === 401) {
throw new Error('Неверный API ключ GPTunnel');
} else if (response.status === 429) {
throw new Error('Превышен лимит запросов к GPTunnel');
} else {
throw new Error(`Ошибка при обращении к CampFireAI: ${response.status} ${response.statusText}`);
}
}
const data = await response.json();
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
throw new Error('Неожиданный ответ от CampFireAI');
}
const aiResponse = data.choices[0].message.content;
// Пытаемся извлечь JSON из ответа
let jsonMatch = aiResponse.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('CampFireAI не вернул валидный JSON');
}
let mediaData;
try {
mediaData = JSON.parse(jsonMatch[0]);
} catch (parseError) {
throw new Error('Ошибка парсинга JSON от CampFireAI');
}
// Валидируем обязательные поля
if (!mediaData.title || !mediaData.type || !mediaData.overview) {
throw new Error('CampFireAI вернул неполные данные');
}
// Формируем данные для импорта
const importData = {
title: mediaData.title,
path: mediaData.path || mediaData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
type: mediaData.type,
overview: mediaData.overview,
release_date: mediaData.release_date || '',
progress_type: mediaData.progress_type || 'completed',
characteristics: mediaData.characteristics || {},
tmdb_id: null,
tmdb_type: null
};
onImport(importData);
onClose();
} catch (err) {
console.error('Error creating with AI:', err);
setError(err.message || 'Произошла ошибка при создании с помощью CampFireAI');
} finally {
setLoading(false);
}
};
return (
<div className="p-6 bg-campfire-charcoal rounded-lg shadow-md text-campfire-light">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-campfire-amber/20 rounded-full mb-4">
<FaMagic className="text-3xl text-campfire-amber" />
</div>
<h2 className="text-2xl font-bold mb-2">CampFireAI</h2>
<p className="text-campfire-ash">
Опишите контент в свободной форме, и ИИ создаст карточку с помощью веб-поиска
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="query" className="block text-sm font-medium text-campfire-light mb-2">
Опишите контент
</label>
<textarea
id="query"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Например: 'Фильм Матрица 1999 года с Киану Ривзом' или 'Игра The Witcher 3: Wild Hunt' или 'Аниме Наруто'"
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"
rows={4}
required
/>
<p className="mt-2 text-sm text-campfire-ash">
Опишите фильм, сериал, игру или аниме в любом формате. CampFireAI найдет информацию и создаст карточку.
</p>
</div>
{error && (
<div className="bg-status-error/20 text-status-error p-4 rounded-md border border-status-error/30">
<div className="mb-2">{error}</div>
{error.includes('оплаты') && (
<div className="text-sm text-status-error/80">
<p>Для использования CampFireAI необходимо:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Пополнить баланс на <a href="https://gptunnel.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-status-error">gptunnel.com</a></li>
<li>Или активировать подписку</li>
<li>Проверить правильность API ключа в .env файле</li>
</ul>
</div>
)}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="btn-secondary flex-1"
disabled={loading}
>
Отмена
</button>
<button
type="submit"
disabled={loading || !query.trim()}
className="btn-primary flex-1 flex items-center justify-center gap-2"
>
{loading ? (
<>
<FaSpinner className="animate-spin" />
<span>Создаю...</span>
</>
) : (
<>
<FaRobot />
<span>Создать с CampFireAI</span>
</>
)}
</button>
</div>
</form>
<div className="mt-6 p-4 bg-campfire-dark/50 rounded-lg">
<h3 className="font-semibold text-campfire-amber mb-2">Как это работает?</h3>
<ul className="text-sm text-campfire-ash space-y-1">
<li> CampFireAI анализирует ваш запрос</li>
<li> Выполняет веб-поиск для получения актуальной информации</li>
<li> Создает структурированную карточку медиа</li>
<li> Вы можете отредактировать данные перед сохранением</li>
</ul>
<div className="mt-4 pt-4 border-t border-campfire-ash/20">
<h4 className="font-medium text-campfire-amber mb-2">Настройка API ключа:</h4>
<ol className="text-sm text-campfire-ash space-y-1 list-decimal list-inside">
<li>Создайте файл <code className="bg-campfire-dark px-1 rounded">.env</code> в корне проекта</li>
<li>Добавьте строку: <code className="bg-campfire-dark px-1 rounded">VITE_GPTUNNEL_API=ваш_ключ</code></li>
<li>Получите API ключ на <a href="https://gptunnel.com" target="_blank" rel="noopener noreferrer" className="text-campfire-amber hover:underline">gptunnel.com</a></li>
<li>Перезапустите dev сервер</li>
</ol>
</div>
</div>
</div>
);
};
export default AICreateModal;

View File

@ -0,0 +1,187 @@
import React, { useState } from 'react';
import { FaRobot, FaSpinner } from 'react-icons/fa';
import { createReviewViaAPI, getReviewsByMediaId } from '../../services/pocketbaseService';
const AIReviewModal = ({ media, onClose, onSuccess }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [review, setReview] = useState(null);
const generateAIReview = async () => {
setLoading(true);
setError(null);
try {
// Проверяем, является ли characteristics уже объектом
let characteristics;
try {
characteristics = typeof media.characteristics === 'string'
? JSON.parse(media.characteristics)
: media.characteristics;
} catch (e) {
console.error('Ошибка при парсинге characteristics:', e);
characteristics = media.characteristics;
}
// Получаем существующие рецензии для обучения ИИ
const existingReviews = await getReviewsByMediaId(media.id);
const reviewsContent = existingReviews.map(r => r.content).join('\n\n');
const prompt = `Ты - эксперт по оценке ${media.type === 'movie' ? 'фильмов' : media.type === 'tv' ? 'сериалов' : media.type === 'anime' ? 'аниме' : 'игр'} на сайте Campfire Critics.
Твоя задача - написать рецензию на "${media.title}", основываясь на мнении пользователей сайта.
Вот существующие рецензии пользователей на это произведение:
${reviewsContent}
Напиши рецензию, которая отражает общее мнение пользователей сайта, но при этом сохраняет профессиональный тон.
Оцени следующие характеристики по шкале от 1 до 10: ${Object.keys(characteristics).join(', ')}.
Ответ должен быть в формате JSON:
{
"content": "текст рецензии",
"ratings": {
"характеристика1": число,
"характеристика2": число,
...
}
}`;
const response = await fetch('/api/generate-review', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
prompt,
model: 'google/gemma-3-27b-it:free',
temperature: 0.7,
max_tokens: 1000
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'Ошибка при генерации рецензии');
}
const data = await response.json();
// Извлекаем JSON из markdown-формата
let aiResponseText = data.choices[0].message.content;
// Ищем JSON в тексте (может быть обернут в ```json или просто быть чистым JSON)
const jsonMatch = aiResponseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/) ||
aiResponseText.match(/(\{[\s\S]*\})/);
if (!jsonMatch) {
throw new Error('Не удалось найти JSON в ответе ИИ');
}
try {
const aiResponse = JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (!aiResponse || typeof aiResponse !== 'object') {
console.error('Неверный формат ответа:', aiResponse);
throw new Error('Неверный формат ответа от ИИ');
}
if (!aiResponse.content || !aiResponse.ratings) {
console.error('Отсутствуют обязательные поля:', aiResponse);
throw new Error('Отсутствуют обязательные поля в ответе ИИ');
}
// Проверяем, что все оценки являются числами
const ratings = aiResponse.ratings;
for (const [key, value] of Object.entries(ratings)) {
if (typeof value !== 'number' || isNaN(value)) {
ratings[key] = Number(value);
if (isNaN(ratings[key])) {
throw new Error(`Некорректное значение оценки для характеристики "${key}"`);
}
}
}
// Рассчитываем общую оценку
const overallRating = Object.values(ratings).reduce((sum, value) => sum + value, 0) / Object.keys(ratings).length;
// Создаем рецензию
const reviewData = {
media_id: media.id,
user_id: 'g520s25pzm0t6e1',
content: aiResponse.content,
ratings: aiResponse.ratings,
has_spoilers: false,
media_type: media.type,
overall_rating: parseFloat(overallRating.toFixed(2)),
progress: media.type === 'game' ? 'completed' : 'watched'
};
const createdReview = await createReviewViaAPI(reviewData);
setReview(createdReview);
onSuccess(createdReview);
} catch (parseError) {
console.error('Ошибка парсинга JSON:', parseError);
console.error('Проблемный текст:', aiResponseText);
throw new Error('Неверный формат JSON в ответе ИИ');
}
} catch (err) {
console.error('Ошибка при генерации рецензии:', err);
setError(err.message || 'Произошла ошибка при генерации рецензии');
} finally {
setLoading(false);
}
};
return (
<div className="p-6 bg-campfire-charcoal rounded-lg shadow-md text-campfire-light">
<h2 className="text-2xl font-bold mb-6">ИИ-рецензия для {media.title}</h2>
{error && (
<div className="bg-status-error/20 text-status-error p-4 rounded-md mb-6">
{error}
</div>
)}
{!review ? (
<div className="text-center">
<p className="mb-6 text-campfire-ash">
Нажмите кнопку ниже, чтобы сгенерировать рецензию с помощью ИИ
</p>
<button
onClick={generateAIReview}
disabled={loading}
className="btn-primary flex items-center gap-2 mx-auto"
>
{loading ? <FaSpinner className="animate-spin" /> : <FaRobot />}
<span>{loading ? 'Генерация...' : 'Сгенерировать рецензию'}</span>
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-campfire-dark p-4 rounded-lg">
<h3 className="font-semibold mb-2">Рецензия:</h3>
<p className="text-campfire-ash">{review.content}</p>
</div>
<div className="bg-campfire-dark p-4 rounded-lg">
<h3 className="font-semibold mb-2">Оценки:</h3>
<div className="grid grid-cols-2 gap-4">
{Object.entries(review.ratings).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-campfire-ash">{key}:</span>
<span className="text-campfire-light">{value}/10</span>
</div>
))}
</div>
</div>
<div className="flex justify-end mt-6">
<button onClick={onClose} className="btn-secondary">
Закрыть
</button>
</div>
</div>
)}
</div>
);
};
export default AIReviewModal;

View File

@ -1,15 +1,57 @@
import React, { useState, useEffect } from 'react';
import { pb } from '../../services/pocketbaseService'; // Import pb for create/update
import * as FaIcons from 'react-icons/fa';
import * as MdIcons from 'react-icons/md';
import * as IoIcons from 'react-icons/io5';
import * as BiIcons from 'react-icons/bi';
import * as HiIcons from 'react-icons/hi';
import * as RiIcons from 'react-icons/ri';
import { FaSearch } from 'react-icons/fa';
const AchievementForm = ({ onSuccess, onCancel, achievementToEdit }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
xp_reward: 0,
// Add other fields like icon if you have them
icon: 'FaTrophy'
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [showIconPicker, setShowIconPicker] = useState(false);
const [selectedIconSet, setSelectedIconSet] = useState('fa');
// Объединяем все доступные иконки
const allIcons = {
fa: Object.keys(FaIcons),
md: Object.keys(MdIcons),
io: Object.keys(IoIcons),
bi: Object.keys(BiIcons),
hi: Object.keys(HiIcons),
ri: Object.keys(RiIcons)
};
// Получаем текущий набор иконок
const currentIcons = allIcons[selectedIconSet] || allIcons.fa;
// Фильтруем иконки по поисковому запросу
const filteredIcons = currentIcons.filter(iconName =>
iconName.toLowerCase().includes(searchQuery.toLowerCase())
);
// Получаем компонент иконки по имени
const getIconComponent = (iconName) => {
const prefix = iconName.substring(0, 2).toLowerCase();
switch (prefix) {
case 'fa': return FaIcons[iconName];
case 'md': return MdIcons[iconName];
case 'io': return IoIcons[iconName];
case 'bi': return BiIcons[iconName];
case 'hi': return HiIcons[iconName];
case 'ri': return RiIcons[iconName];
default: return FaIcons.FaTrophy;
}
};
useEffect(() => {
if (achievementToEdit) {
@ -17,15 +59,15 @@ const AchievementForm = ({ onSuccess, onCancel, achievementToEdit }) => {
title: achievementToEdit.title,
description: achievementToEdit.description,
xp_reward: achievementToEdit.xp_reward,
// Load other fields here
icon: achievementToEdit.icon || 'FaTrophy'
});
} else {
// Reset form if creating a new achievement
setFormData({
title: '',
description: '',
xp_reward: 0,
});
setFormData({
title: '',
description: '',
xp_reward: 0,
icon: 'FaTrophy'
});
}
}, [achievementToEdit]);
@ -33,10 +75,18 @@ const AchievementForm = ({ onSuccess, onCancel, achievementToEdit }) => {
const { name, value, type } = e.target;
setFormData({
...formData,
[name]: type === 'number' ? parseInt(value, 10) || 0 : value, // Parse number input
[name]: type === 'number' ? parseInt(value, 10) || 0 : value,
});
};
const handleIconSelect = (iconName) => {
setFormData(prev => ({
...prev,
icon: iconName
}));
setShowIconPicker(false);
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
@ -45,17 +95,11 @@ const AchievementForm = ({ onSuccess, onCancel, achievementToEdit }) => {
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
onSuccess();
} catch (err) {
console.error('Error saving achievement:', err);
setError(err.message);
@ -64,6 +108,8 @@ const AchievementForm = ({ onSuccess, onCancel, achievementToEdit }) => {
}
};
const IconComponent = getIconComponent(formData.icon);
return (
<form onSubmit={handleSubmit} className="p-4">
{error && (
@ -112,20 +158,74 @@ const AchievementForm = ({ onSuccess, onCancel, achievementToEdit }) => {
/>
</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>
*/}
<label className="block text-sm font-medium text-campfire-ash mb-1">Иконка</label>
<div className="relative">
<button
type="button"
onClick={() => setShowIconPicker(!showIconPicker)}
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 flex items-center justify-between"
>
<div className="flex items-center space-x-2">
<IconComponent className="text-campfire-amber" size={20} />
<span>{formData.icon}</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
{showIconPicker && (
<div className="absolute z-10 mt-1 w-full bg-campfire-dark border border-campfire-ash/30 rounded-md shadow-lg">
<div className="p-2 border-b border-campfire-ash/30">
<div className="flex space-x-2 mb-2">
{Object.keys(allIcons).map(set => (
<button
key={set}
type="button"
onClick={() => setSelectedIconSet(set)}
className={`px-3 py-1 rounded-md text-sm ${
selectedIconSet === set
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal text-campfire-light hover:bg-campfire-amber/10'
}`}
>
{set.toUpperCase()}
</button>
))}
</div>
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск иконок..."
className="w-full px-3 py-2 pl-8 bg-campfire-charcoal text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber"
/>
<FaSearch className="absolute left-2 top-3 text-campfire-ash" />
</div>
</div>
<div className="max-h-60 overflow-y-auto p-2 grid grid-cols-6 gap-2">
{filteredIcons.map(iconName => {
const Icon = getIconComponent(iconName);
return (
<button
key={iconName}
type="button"
onClick={() => handleIconSelect(iconName)}
className={`p-2 rounded-md flex items-center justify-center hover:bg-campfire-amber/10 transition-colors ${
formData.icon === iconName ? 'bg-campfire-amber/20' : ''
}`}
>
<Icon className="text-campfire-amber" size={20} />
</button>
);
})}
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end space-x-4 mt-6">
<button

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect, useMemo } from 'react'; // Import useMemo
import { createMedia, updateMedia, validateMediaData, formatMediaData, getFileUrl, mediaTypes } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
import { FaUpload, FaTimesCircle } from 'react-icons/fa'; // Import icons
import { FaUpload, FaTimesCircle, FaTrash, FaCrop } from 'react-icons/fa'; // Import icons
import { v4 as uuidv4 } from 'uuid'; // Import uuid for unique IDs
import ImageCropper from '../common/ImageCropper';
// Define characteristic presets
const characteristicPresets = {
@ -64,16 +65,27 @@ function MediaForm({ media, onSuccess }) {
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 [tmdbId, setTmdbId] = useState(null);
const [tmdbType, setTmdbType] = useState(null);
const [posterPreview, setPosterPreview] = useState(null);
const [backdropPreview, setBackdropPreview] = useState(null);
const [posterPosition, setPosterPosition] = useState({ x: 50, y: 50 }); // Добавляем состояние для позиции постера
const [backdropPosition, setBackdropPosition] = useState({ x: 50, y: 50 }); // Добавляем состояние для позиции баннера
const [posterScale, setPosterScale] = useState(1); // Добавляем состояние для масштаба постера
const [backdropScale, setBackdropScale] = useState(1); // Добавляем состояние для масштаба баннера
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState([]);
const { user } = useAuth(); // Get current user
const isEditing = !!media; // Determine if we are editing or creating
const isEditing = !!(media && media.id); // Determine if we are editing or creating
const [showPosterCropper, setShowPosterCropper] = useState(false);
const [showBackdropCropper, setShowBackdropCropper] = useState(false);
const [tempPosterFile, setTempPosterFile] = useState(null);
const [tempBackdropFile, setTempBackdropFile] = useState(null);
useEffect(() => {
if (media) {
@ -81,39 +93,92 @@ function MediaForm({ media, onSuccess }) {
setTitle(media.title || '');
setPath(media.path || '');
setType(media.type || '');
setOverview(media.overview || media.description || ''); // Use overview or description
setOverview(media.overview || media.description || '');
setTmdbId(media.tmdb_id || null);
setTmdbType(media.tmdb_type || null);
// 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
if (media.id) {
// Only parse characteristics for existing media records
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
}));
// 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
} catch (e) {
console.error("Failed to parse characteristics JSON:", e);
initialCharacteristicsArray = []; // Default to empty array on error
}
} else {
// For imported media, check if characteristics are provided by AI or use defaults
if (media.characteristics && typeof media.characteristics === 'object' && Object.keys(media.characteristics).length > 0) {
// Use characteristics provided by AI
initialCharacteristicsArray = Object.entries(media.characteristics).map(([key, label]) => ({
id: uuidv4(),
key: key,
label: label
}));
} else {
// Use default characteristics based on type
const defaultCharacteristics = characteristicPresets[media.type] || [];
initialCharacteristicsArray = defaultCharacteristics.map(char => ({
id: uuidv4(),
key: char.key,
label: char.label
}));
}
// Also set default values for other fields if not provided
if (!media.is_published) setIsPublished(false);
if (!media.is_popular) setIsPopular(false);
if (!media.progress_type) setProgressType('completed');
}
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] : '');
if (media.id) {
// For existing media, use stored values
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] : '');
} else {
// For imported media, use provided values or defaults
setIsPublished(media.is_published ?? false);
setIsPopular(media.is_popular ?? false);
setProgressType(media.progress_type || 'completed');
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'));
// Set initial previews for existing files (only if media has an id, meaning it's an existing record)
if (media.id) {
setPosterPreview(getFileUrl(media, 'poster'));
setBackdropPreview(getFileUrl(media, 'backdrop'));
} else {
// For imported media, set previews from File objects if available
if (media.poster instanceof File) {
setPosterPreview(URL.createObjectURL(media.poster));
setPoster(media.poster);
} else {
setPosterPreview(null);
}
if (media.backdrop instanceof File) {
setBackdropPreview(URL.createObjectURL(media.backdrop));
setBackdrop(media.backdrop);
} else {
setBackdropPreview(null);
}
}
} else {
// Reset form for creation
@ -128,6 +193,8 @@ function MediaForm({ media, onSuccess }) {
setIsPopular(false); // Reset is_popular
setProgressType('completed'); // Default progressType to 'completed' for new media
setReleaseDate(''); // Reset release_date
setTmdbId(null);
setTmdbType(null);
setPosterPreview(null);
setBackdropPreview(null);
}
@ -244,11 +311,11 @@ function MediaForm({ media, onSuccess }) {
const file = e.target.files[0];
if (file) {
if (field === 'poster') {
setPoster(file);
setPosterPreview(URL.createObjectURL(file));
setTempPosterFile(file);
setShowPosterCropper(true);
} else if (field === 'backdrop') {
setBackdrop(file);
setBackdropPreview(URL.createObjectURL(file));
setTempBackdropFile(file);
setShowBackdropCropper(true);
}
}
};
@ -263,6 +330,23 @@ function MediaForm({ media, onSuccess }) {
}
};
// Добавляем функцию для обработки изменения позиции
const handlePositionChange = (field, x, y) => {
if (field === 'poster') {
setPosterPosition({ x, y });
} else if (field === 'backdrop') {
setBackdropPosition({ x, y });
}
};
// Добавляем функцию для обработки изменения масштаба
const handleScaleChange = (field, scale) => {
if (field === 'poster') {
setPosterScale(scale);
} else if (field === 'backdrop') {
setBackdropScale(scale);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
@ -273,40 +357,64 @@ function MediaForm({ media, onSuccess }) {
formData.append('title', title);
formData.append('path', path);
formData.append('type', type);
formData.append('overview', overview); // Use overview field
formData.append('overview', overview);
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
formData.append('is_popular', isPopular);
formData.append('progress_type', progressType);
if (releaseDate) {
formData.append('release_date', releaseDate);
}
// Append files if they are new File objects
if (poster instanceof File) {
formData.append('poster', poster);
if (tmdbId) {
formData.append('tmdb_id', tmdbId);
}
if (backdrop instanceof File) {
formData.append('backdrop', backdrop);
if (tmdbType) {
formData.append('tmdb_type', tmdbType);
}
// 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 (poster instanceof File) {
// Если есть новый файл
formData.append('poster', poster);
// Если poster_src не существует или равен N/A, сохраняем оригинальный файл
if (!media.poster_src || media.poster_src === 'N/A') {
formData.append('poster_src', poster);
}
// 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
} else if (!posterPreview && media.poster) {
// Если файл удален
formData.append('poster', '');
formData.append('poster_src', '');
}
if (backdrop instanceof File) {
// Если есть новый файл
formData.append('backdrop', backdrop);
// Если backdrop_src не существует или равен N/A, сохраняем оригинальный файл
if (!media.backdrop_src || media.backdrop_src === 'N/A') {
formData.append('backdrop_src', backdrop);
}
} else if (!backdropPreview && media.backdrop) {
// Если файл удален
formData.append('backdrop', '');
formData.append('backdrop_src', '');
}
} else {
// При создании
if (poster instanceof File) {
formData.append('poster_src', poster);
formData.append('poster', poster);
}
if (backdrop instanceof File) {
formData.append('backdrop_src', backdrop);
formData.append('backdrop', backdrop);
}
}
// Convert characteristics array [{ id, key, label }] back to object { key: label } for PocketBase
// Convert characteristics array to object
const characteristicsObject = characteristics.reduce((acc, char) => {
// Only include if key is not empty
if (char.key.trim()) {
acc[char.key.trim()] = char.label;
}
@ -321,26 +429,12 @@ function MediaForm({ media, onSuccess }) {
formData.append('created_by', user.id);
}
// Validate data before submitting
// Pass the characteristics object for validation
const validationErrors = validateMediaData(formData, characteristicsObject);
if (validationErrors.length > 0) {
setErrors(validationErrors);
setLoading(false);
return;
}
// Format data (less critical with FormData, but can be used for final checks)
const dataToSubmit = formatMediaData(formData);
try {
if (isEditing) {
await updateMedia(media.id, dataToSubmit);
await updateMedia(media.id, formData);
console.log('Media updated successfully');
} else {
await createMedia(dataToSubmit);
await createMedia(formData);
console.log('Media created successfully');
}
onSuccess(); // Close modal and refresh list
@ -348,7 +442,6 @@ function MediaForm({ media, onSuccess }) {
console.error('Error submitting media form:', err);
if (err.response && err.response.data) {
console.error('PocketBase Response Data:', err.response.data);
// Attempt to extract specific error messages from PocketBase response
const apiErrors = [];
for (const field in err.response.data) {
if (err.response.data[field].message) {
@ -368,6 +461,66 @@ function MediaForm({ media, onSuccess }) {
}
};
const handleCrop = (blob, type) => {
const file = new File([blob], type === 'poster' ? 'poster.jpg' : 'backdrop.jpg', {
type: 'image/jpeg'
});
if (type === 'poster') {
setPoster(file);
setPosterPreview(URL.createObjectURL(blob));
setShowPosterCropper(false);
setTempPosterFile(null);
} else {
setBackdrop(file);
setBackdropPreview(URL.createObjectURL(blob));
setShowBackdropCropper(false);
setTempBackdropFile(null);
}
};
const handleRecrop = async (type) => {
try {
let file;
if (type === 'poster') {
if (media?.poster_src) {
// Используем оригинальный файл из poster_src
const response = await fetch(getFileUrl(media, 'poster_src'));
const blob = await response.blob();
file = new File([blob], 'poster.jpg', { type: 'image/jpeg' });
} else if (poster instanceof File) {
file = poster;
} else if (media?.poster) {
const response = await fetch(getFileUrl(media, 'poster'));
const blob = await response.blob();
file = new File([blob], 'poster.jpg', { type: 'image/jpeg' });
}
if (file) {
setTempPosterFile(file);
setShowPosterCropper(true);
}
} else if (type === 'backdrop') {
if (media?.backdrop_src) {
// Используем оригинальный файл из backdrop_src
const response = await fetch(getFileUrl(media, 'backdrop_src'));
const blob = await response.blob();
file = new File([blob], 'backdrop.jpg', { type: 'image/jpeg' });
} else if (backdrop instanceof File) {
file = backdrop;
} else if (media?.backdrop) {
const response = await fetch(getFileUrl(media, 'backdrop'));
const blob = await response.blob();
file = new File([blob], 'backdrop.jpg', { type: 'image/jpeg' });
}
if (file) {
setTempBackdropFile(file);
setShowBackdropCropper(true);
}
}
} catch (error) {
console.error('Error preparing image for recrop:', error);
}
};
return (
<div className="p-6 bg-campfire-charcoal rounded-lg shadow-md text-campfire-light max-h-[80vh] overflow-y-auto custom-scrollbar">
@ -488,89 +641,107 @@ function MediaForm({ media, onSuccess }) {
{/* File Uploads */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Poster Upload */}
{/* Poster Upload with Controls */}
<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">
<div className="relative">
{posterPreview ? (
<>
<img src={posterPreview} alt="Poster Preview" className="max-h-40 object-contain" />
<div className="relative">
<img
src={posterPreview}
alt="Poster preview"
className="w-full h-64 object-contain rounded-lg"
/>
<div className="absolute top-2 right-2 flex gap-2">
<button
type="button"
onClick={() => handleRecrop('poster')}
className="p-2 bg-campfire-amber text-campfire-dark rounded-full hover:bg-campfire-amber/80"
>
<FaCrop />
</button>
<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"
className="p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
>
<FaTimesCircle size={20} />
<FaTrash />
</button>
</>
</div>
</div>
) : (
<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">
<div className="border-2 border-dashed border-campfire-ash rounded-lg p-6 text-center">
<input
type="file"
accept="image/*"
onChange={(e) => handleFileChange(e, 'poster')}
className="hidden"
id="poster-upload"
/>
<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/*"
/>
className="cursor-pointer flex flex-col items-center"
>
<FaUpload className="text-3xl text-campfire-ash mb-2" />
<span className="text-campfire-ash">
Нажмите для загрузки постера
</span>
</label>
<p className="pl-1">или перетащите сюда</p>
</div>
<p className="text-xs text-campfire-ash">PNG, JPG, GIF до 10MB</p>
</div>
)}
</div>
</div>
{/* Backdrop Upload */}
{/* Backdrop Upload with Controls */}
<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">
<div className="relative">
{backdropPreview ? (
<>
<img src={backdropPreview} alt="Backdrop Preview" className="max-h-40 object-contain" />
<div className="relative">
<img
src={backdropPreview}
alt="Backdrop preview"
className="w-full h-64 object-cover rounded-lg"
/>
<div className="absolute top-2 right-2 flex gap-2">
<button
type="button"
onClick={() => handleRecrop('backdrop')}
className="p-2 bg-campfire-amber text-campfire-dark rounded-full hover:bg-campfire-amber/80"
>
<FaCrop />
</button>
<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"
className="p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
>
<FaTimesCircle size={20} />
<FaTrash />
</button>
</>
</div>
</div>
) : (
<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">
<div className="border-2 border-dashed border-campfire-ash rounded-lg p-6 text-center">
<input
type="file"
accept="image/*"
onChange={(e) => handleFileChange(e, 'backdrop')}
className="hidden"
id="backdrop-upload"
/>
<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/*"
/>
className="cursor-pointer flex flex-col items-center"
>
<FaUpload className="text-3xl text-campfire-ash mb-2" />
<span className="text-campfire-ash">
Нажмите для загрузки баннера
</span>
</label>
<p className="pl-1">или перетащите сюда</p>
</div>
<p className="text-xs text-campfire-ash">PNG, JPG, GIF до 10MB</p>
</div>
)}
</div>
@ -706,6 +877,34 @@ function MediaForm({ media, onSuccess }) {
</button>
</div>
</form>
{showPosterCropper && tempPosterFile && (
<ImageCropper
imageUrl={URL.createObjectURL(tempPosterFile)}
onCrop={(blob) => handleCrop(blob, 'poster')}
onCancel={() => {
setShowPosterCropper(false);
setTempPosterFile(null);
}}
aspectRatio={2/3}
maxWidth={500}
maxHeight={750}
/>
)}
{showBackdropCropper && tempBackdropFile && (
<ImageCropper
imageUrl={URL.createObjectURL(tempBackdropFile)}
onCrop={(blob) => handleCrop(blob, 'backdrop')}
onCancel={() => {
setShowBackdropCropper(false);
setTempBackdropFile(null);
}}
aspectRatio={16/9}
maxWidth={1920}
maxHeight={1080}
/>
)}
</div>
);
}

View File

@ -0,0 +1,332 @@
import React, { useState, useEffect } from 'react';
import { pb } from '../../services/pocketbaseService';
import { FaBell, FaUsers, FaFilter, FaPaperPlane, FaPlus, FaTrash } from 'react-icons/fa';
const NotificationManager = () => {
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [filters, setFilters] = useState([{ field: '', operator: '=', value: '' }]);
const [mediaList, setMediaList] = useState([]);
const [selectedMedia, setSelectedMedia] = useState([]);
const [minReviews, setMinReviews] = useState(1);
useEffect(() => {
loadMediaList();
}, []);
const loadMediaList = async () => {
try {
const records = await pb.collection('media').getList(1, 1000, {
sort: 'title'
});
setMediaList(records.items);
} catch (error) {
console.error('Error loading media list:', error);
}
};
const operators = [
{ value: '=', label: 'Равно' },
{ value: '!=', label: 'Не равно' },
{ value: '>', label: 'Больше' },
{ value: '<', label: 'Меньше' },
{ value: '>=', label: 'Больше или равно' },
{ value: '<=', label: 'Меньше или равно' },
{ value: '~', label: 'Содержит' },
{ value: '!~', label: 'Не содержит' }
];
const fields = [
{ value: 'level', label: 'Уровень' },
{ value: 'review_count', label: 'Количество рецензий' },
{ value: 'email', label: 'Email' },
{ value: 'showcase', label: 'Витрина' },
{ value: 'created', label: 'Дата регистрации' },
{ value: 'has_reviewed', label: 'Оставил рецензию на медиа' }
];
const addFilter = () => {
setFilters([...filters, { field: '', operator: '=', value: '' }]);
};
const removeFilter = (index) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index, field, value) => {
const newFilters = [...filters];
newFilters[index][field] = value;
setFilters(newFilters);
};
const buildFilterString = () => {
return filters
.filter(f => f.field && f.operator && f.value)
.map(f => {
let value = f.value;
// Если значение не число и не булево, заключаем в кавычки
if (isNaN(value) && value !== 'true' && value !== 'false') {
value = `"${value}"`;
}
return `${f.field} ${f.operator} ${value}`;
})
.join(' && ');
};
const handleMediaSelect = (mediaId) => {
setSelectedMedia(prev => {
if (prev.includes(mediaId)) {
return prev.filter(id => id !== mediaId);
} else {
return [...prev, mediaId];
}
});
};
const sendNotification = async () => {
if (!title || !message) {
setError('Заполните заголовок и сообщение');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
let filterString = buildFilterString();
// Если выбран фильтр по медиа, добавляем подзапрос для получения пользователей с рецензиями
if (selectedMedia.length > 0) {
const reviews = await pb.collection('reviews').getList(1, 1000, {
filter: `media_id ?~ "${selectedMedia.join('" || media_id ?~ "')}"`,
fields: 'user_id'
});
// Группируем рецензии по пользователям
const userReviews = reviews.items.reduce((acc, review) => {
acc[review.user_id] = (acc[review.user_id] || 0) + 1;
return acc;
}, {});
// Фильтруем пользователей по минимальному количеству рецензий
const userIds = Object.entries(userReviews)
.filter(([_, count]) => count >= minReviews)
.map(([userId]) => userId);
if (userIds.length > 0) {
const mediaFilter = `id ?~ "${userIds.join('" || id ?~ "')}"`;
filterString = filterString ? `${filterString} && ${mediaFilter}` : mediaFilter;
} else {
throw new Error('Нет пользователей, соответствующих выбранным критериям');
}
}
// Получаем список пользователей по фильтру
const users = await pb.collection('users').getList(1, 1000, {
filter: filterString || undefined
});
if (users.items.length === 0) {
throw new Error('Нет пользователей, соответствующих выбранным фильтрам');
}
// Отправляем уведомления каждому пользователю
for (const user of users.items) {
await pb.collection('notifications').create({
user_id: user.id,
type: 'system',
title,
message,
is_read: false
});
}
setSuccess(`Уведомления отправлены ${users.items.length} пользователям`);
setTitle('');
setMessage('');
setFilters([{ field: '', operator: '=', value: '' }]);
setSelectedMedia([]);
setMinReviews(1);
} catch (error) {
console.error('Error sending notifications:', error);
setError(error.message || 'Произошла ошибка при отправке уведомлений');
} finally {
setLoading(false);
}
};
return (
<div className="bg-campfire-dark rounded-lg p-6">
<h2 className="text-2xl font-bold text-campfire-light mb-6 flex items-center">
<FaBell className="mr-2" />
Отправка уведомлений
</h2>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg mb-4">
{error}
</div>
)}
{success && (
<div className="bg-green-500/10 border border-green-500/20 text-green-500 p-4 rounded-lg mb-4">
{success}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-campfire-light mb-2">
Заголовок
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-2 bg-campfire-charcoal border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
placeholder="Введите заголовок уведомления"
/>
</div>
<div>
<label className="block text-sm font-medium text-campfire-light mb-2">
Сообщение
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full p-2 bg-campfire-charcoal border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
rows="4"
placeholder="Введите текст уведомления"
/>
</div>
<div>
<label className="block text-sm font-medium text-campfire-light mb-2">
Фильтр по рецензиям
</label>
<div className="space-y-4">
<div>
<label className="block text-sm text-campfire-light/70 mb-2">
Минимальное количество рецензий
</label>
<input
type="number"
min="1"
value={minReviews}
onChange={(e) => setMinReviews(parseInt(e.target.value) || 1)}
className="w-full p-2 bg-campfire-charcoal border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm text-campfire-light/70 mb-2">
Выберите медиа
</label>
<div className="max-h-48 overflow-y-auto bg-campfire-charcoal border border-campfire-ash/30 rounded-md">
{mediaList.map(media => (
<div
key={media.id}
className="flex items-center space-x-2 p-2 hover:bg-campfire-ash/20 cursor-pointer"
onClick={() => handleMediaSelect(media.id)}
>
<input
type="checkbox"
checked={selectedMedia.includes(media.id)}
onChange={() => {}}
className="text-campfire-amber focus:ring-campfire-amber"
/>
<span className="text-campfire-light">{media.title}</span>
</div>
))}
</div>
</div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<label className="block text-sm font-medium text-campfire-light">
Дополнительные фильтры
</label>
<button
onClick={addFilter}
className="flex items-center text-campfire-amber hover:text-campfire-amber/80"
>
<FaPlus className="mr-1" />
Добавить фильтр
</button>
</div>
<div className="space-y-2">
{filters.map((filter, index) => (
<div key={index} className="flex items-center space-x-2">
<select
value={filter.field}
onChange={(e) => updateFilter(index, 'field', e.target.value)}
className="flex-1 p-2 bg-campfire-charcoal border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
>
<option value="">Выберите поле</option>
{fields.map(field => (
<option key={field.value} value={field.value}>
{field.label}
</option>
))}
</select>
<select
value={filter.operator}
onChange={(e) => updateFilter(index, 'operator', e.target.value)}
className="w-32 p-2 bg-campfire-charcoal border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
>
{operators.map(op => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
<input
type="text"
value={filter.value}
onChange={(e) => updateFilter(index, 'value', e.target.value)}
className="flex-1 p-2 bg-campfire-charcoal border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
placeholder="Значение"
/>
{filters.length > 1 && (
<button
onClick={() => removeFilter(index)}
className="p-2 text-red-500 hover:text-red-400"
>
<FaTrash />
</button>
)}
</div>
))}
</div>
</div>
<button
onClick={sendNotification}
disabled={loading}
className="w-full py-2 px-4 bg-campfire-amber text-campfire-dark rounded-md hover:bg-campfire-amber/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{loading ? (
'Отправка...'
) : (
<>
<FaPaperPlane className="mr-2" />
Отправить уведомления
</>
)}
</button>
</div>
</div>
);
};
export default NotificationManager;

View File

@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { FaSearch, FaSpinner } from 'react-icons/fa';
const TMDBImportModal = ({ onClose, onImport }) => {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const searchTMDB = async (query) => {
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`https://api.themoviedb.org/3/search/multi?api_key=ba3a2d0e9b1ceaf29511da9f2d26bf91&query=${encodeURIComponent(query)}&language=ru-RU`,
{
headers: {
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJiYTNhMmQwZTliMWNlYWYyOTUxMWRhOWYyZDI2YmY5MSIsIm5iZiI6MTc0NTc0MjkwNy4yMTQwMDAyLCJzdWIiOiI2ODBkZWMzYjE2MzVkMjkxOGM4MGZkN2IiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.xHuQc27ZvJWzl1oLdBK50nMSxNsETd863gjnDnOuyCU'
}
}
);
if (!response.ok) {
throw new Error('Ошибка при поиске в TMDB');
}
const data = await response.json();
setSearchResults(data.results.filter(item => item.media_type === 'movie' || item.media_type === 'tv'));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSearch = (e) => {
e.preventDefault();
searchTMDB(searchQuery);
};
const handleImport = async (item) => {
try {
setLoading(true);
setError(null);
// Получаем детальную информацию о фильме/сериале
const response = await fetch(
`https://api.themoviedb.org/3/${item.media_type}/${item.id}?api_key=ba3a2d0e9b1ceaf29511da9f2d26bf91&language=ru-RU`,
{
headers: {
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJiYTNhMmQwZTliMWNlYWYyOTUxMWRhOWYyZDI2YmY5MSIsIm5iZiI6MTc0NTc0MjkwNy4yMTQwMDAyLCJzdWIiOiI2ODBkZWMzYjE2MzVkMjkxOGM4MGZkN2IiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.xHuQc27ZvJWzl1oLdBK50nMSxNsETd863gjnDnOuyCU'
}
}
);
if (!response.ok) {
throw new Error('Ошибка при получении деталей');
}
const details = await response.json();
// Загружаем изображения из TMDB
let posterFile = null;
let backdropFile = null;
if (details.poster_path) {
try {
// Пробуем загрузить изображение с таймаутом
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 секунд таймаут
const posterResponse = await fetch(`https://image.tmdb.org/t/p/w500${details.poster_path}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (posterResponse.ok) {
const posterBlob = await posterResponse.blob();
posterFile = new File([posterBlob], 'poster.jpg', { type: 'image/jpeg' });
console.log('Постер успешно загружен');
}
} catch (err) {
if (err.name === 'AbortError') {
console.warn('Таймаут загрузки постера');
} else {
console.warn('Не удалось загрузить постер:', err.message);
}
}
}
if (details.backdrop_path) {
try {
// Пробуем загрузить изображение с таймаутом
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 секунд таймаут
const backdropResponse = await fetch(`https://image.tmdb.org/t/p/w1280${details.backdrop_path}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (backdropResponse.ok) {
const backdropBlob = await backdropResponse.blob();
backdropFile = new File([backdropBlob], 'backdrop.jpg', { type: 'image/jpeg' });
console.log('Баннер успешно загружен');
}
} catch (err) {
if (err.name === 'AbortError') {
console.warn('Таймаут загрузки баннера');
} else {
console.warn('Не удалось загрузить баннер:', err.message);
}
}
}
// Формируем данные для импорта
const importData = {
title: details.title || details.name,
path: (details.title || details.name).toLowerCase().replace(/[^a-z0-9]+/g, '-'),
type: item.media_type === 'movie' ? 'movie' : 'tv',
overview: details.overview,
release_date: details.release_date || details.first_air_date,
poster: posterFile,
backdrop: backdropFile,
tmdb_id: details.id,
tmdb_type: item.media_type
};
// Показываем информацию о загруженных изображениях
if (posterFile || backdropFile) {
console.log('Импорт завершен:', {
title: importData.title,
poster: posterFile ? '✓ Загружен' : '✗ Не загружен',
backdrop: backdropFile ? '✓ Загружен' : '✗ Не загружен'
});
}
onImport(importData);
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="p-6 bg-campfire-charcoal rounded-lg shadow-md text-campfire-light">
<h2 className="text-2xl font-bold mb-6">Импорт из TMDB</h2>
<form onSubmit={handleSearch} className="mb-6">
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск фильмов и сериалов..."
className="flex-1 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"
/>
<button
type="submit"
disabled={loading}
className="btn-primary flex items-center gap-2"
>
{loading ? <FaSpinner className="animate-spin" /> : <FaSearch />}
<span>Поиск</span>
</button>
</div>
</form>
{error && (
<div className="bg-status-error/20 text-status-error p-4 rounded-md mb-6">
{error}
</div>
)}
{searchResults.length > 0 && (
<div className="space-y-4 max-h-[60vh] overflow-y-auto custom-scrollbar">
{searchResults.map((item) => (
<div
key={item.id}
className="flex items-center gap-4 p-4 bg-campfire-dark rounded-lg hover:bg-campfire-ash/20 transition-colors"
>
{item.poster_path && (
<img
src={`https://image.tmdb.org/t/p/w92${item.poster_path}`}
alt={item.title || item.name}
className="w-16 h-24 object-cover rounded"
/>
)}
<div className="flex-1">
<h3 className="font-semibold">{item.title || item.name}</h3>
<p className="text-sm text-campfire-ash">
{item.media_type === 'movie' ? 'Фильм' : 'Сериал'} {' '}
{new Date(item.release_date || item.first_air_date).getFullYear()}
</p>
</div>
<button
onClick={() => handleImport(item)}
disabled={loading}
className="btn-secondary"
>
Импорт
</button>
</div>
))}
</div>
)}
</div>
);
};
export default TMDBImportModal;

View File

@ -1,12 +1,11 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // Adjust path if necessary
import { useAuth } from '../../contexts/AuthContext';
const AuthRoute = () => { // Renamed component from ProtectedRoute to AuthRoute
const AuthRoute = ({ children }) => {
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>
@ -14,13 +13,13 @@ const AuthRoute = () => { // Renamed component from ProtectedRoute to AuthRoute
);
}
// If user is not authenticated, redirect to login page
// If user is not authenticated, redirect to internal login page
if (!user) {
return <Navigate to="/auth" replace />;
return <Navigate to="/auth/login" replace />;
}
// If user is authenticated, render the child routes
return <Outlet />;
return children || <Outlet />;
};
export default AuthRoute; // Exporting AuthRoute
export default AuthRoute;

View File

@ -1,12 +1,11 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // Adjust path if necessary
import { useAuth } from '../../contexts/AuthContext';
const GuestRoute = () => {
const GuestRoute = ({ children }) => {
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>
@ -15,17 +14,12 @@ const GuestRoute = () => {
}
// 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 />;
// If user is not authenticated, render the guest-only content
return children || <Outlet />;
};
export default GuestRoute;

View File

@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const ProjectRoute = ({ projectName }) => {
const { user, isInitialized, hasProjectAccess } = useAuth();
const [hasAccess, setHasAccess] = useState(null);
useEffect(() => {
const checkAccess = async () => {
if (user) {
const access = await hasProjectAccess(projectName);
setHasAccess(access);
} else {
setHasAccess(false);
}
};
checkAccess();
}, [user, projectName, hasProjectAccess]);
// Ожидаем инициализации и проверки доступа
if (!isInitialized || hasAccess === null) {
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) {
return <Navigate to="/auth" replace />;
}
// Если у пользователя нет доступа к проекту, показываем сообщение об ошибке
if (!hasAccess) {
return (
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="text-center">
<h1 className="text-2xl font-bold text-campfire-light mb-4">
Доступ запрещен
</h1>
<p className="text-campfire-ash">
У вас нет доступа к проекту {projectName}
</p>
</div>
</div>
);
}
// Если у пользователя есть доступ, отображаем содержимое
return <Outlet />;
};
export default ProjectRoute;

View File

@ -0,0 +1,284 @@
import React, { useState, useRef, useEffect } from 'react';
import { FaCrop, FaCheck, FaTimes } from 'react-icons/fa';
const ImageCropper = ({
imageUrl,
onCrop,
onCancel,
aspectRatio = 2/3, // По умолчанию для постера
maxWidth = 500,
maxHeight = 750
}) => {
const [crop, setCrop] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [isDragging, setIsDragging] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const containerRef = useRef(null);
const imageRef = useRef(null);
// Добавляем обработчик колесика мыши
const handleWheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1; // Уменьшаем при прокрутке вниз, увеличиваем при прокрутке вверх
// Вычисляем новые размеры с сохранением соотношения сторон
const newWidth = Math.max(100, Math.min(imageSize.width, crop.width * (1 + delta)));
const newHeight = newWidth / aspectRatio;
// Проверяем, не выходит ли новая область за пределы изображения
if (newWidth <= imageSize.width && newHeight <= imageSize.height) {
// Центрируем новую область относительно текущей
const newX = crop.x + (crop.width - newWidth) / 2;
const newY = crop.y + (crop.height - newHeight) / 2;
setCrop({
x: Math.max(0, Math.min(newX, imageSize.width - newWidth)),
y: Math.max(0, Math.min(newY, imageSize.height - newHeight)),
width: newWidth,
height: newHeight
});
}
};
useEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
// Получаем оригинальные размеры изображения
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
// Вычисляем размеры для отображения с учетом максимальных размеров окна
const maxWidth = window.innerWidth * 0.8;
const maxHeight = window.innerHeight * 0.6;
// Вычисляем масштаб для отображения
const scale = Math.min(
maxWidth / originalWidth,
maxHeight / originalHeight
);
// Устанавливаем размеры для отображения
const displayWidth = originalWidth * scale;
const displayHeight = originalHeight * scale;
setImageSize({
width: displayWidth,
height: displayHeight,
scale: scale,
originalWidth,
originalHeight
});
// Вычисляем размеры области кадрирования
let cropWidth, cropHeight;
if (aspectRatio === 16/9) { // Для баннера
cropWidth = Math.min(displayWidth, displayHeight * (16/9));
cropHeight = cropWidth / (16/9);
} else if (aspectRatio === 2/3) { // Для постера
cropWidth = Math.min(displayWidth, displayHeight * (2/3));
cropHeight = cropWidth / (2/3);
} else {
cropWidth = displayWidth;
cropHeight = displayHeight;
}
// Центрируем область кадрирования
setCrop({
x: (displayWidth - cropWidth) / 2,
y: (displayHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight
});
};
}
}, [imageUrl, aspectRatio]);
const handleMouseDown = (e) => {
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Проверяем, находится ли клик внутри области кадрирования
if (
x >= crop.x &&
x <= crop.x + crop.width &&
y >= crop.y &&
y <= crop.y + crop.height
) {
setIsDragging(true);
setStartPos({ x: x - crop.x, y: y - crop.y });
}
};
const handleMouseMove = (e) => {
if (!isDragging) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Вычисляем новую позицию с учетом границ
let newX = x - startPos.x;
let newY = y - startPos.y;
// Ограничиваем движение границами изображения
newX = Math.max(0, Math.min(newX, imageSize.width - crop.width));
newY = Math.max(0, Math.min(newY, imageSize.height - crop.height));
setCrop(prev => ({
...prev,
x: newX,
y: newY
}));
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleCrop = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Получаем оригинальные размеры изображения
const { originalWidth, originalHeight, scale } = imageSize;
// Вычисляем размеры для выходного изображения
let targetWidth, targetHeight;
if (aspectRatio === 16/9) { // Для баннера
targetWidth = 1920;
targetHeight = 1080;
} else if (aspectRatio === 2/3) { // Для постера
targetWidth = 1000;
targetHeight = 1500;
} else {
targetWidth = originalWidth;
targetHeight = originalHeight;
}
canvas.width = targetWidth;
canvas.height = targetHeight;
// Вычисляем координаты и размеры в оригинальном масштабе
const sourceX = crop.x / scale;
const sourceY = crop.y / scale;
const sourceWidth = crop.width / scale;
const sourceHeight = crop.height / scale;
// Рисуем кадрированное изображение
ctx.drawImage(
imageRef.current,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
targetWidth,
targetHeight
);
// Получаем кадрированное изображение в виде blob
canvas.toBlob((blob) => {
onCrop(blob);
}, 'image/jpeg', 0.95);
};
return (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div className="bg-campfire-charcoal rounded-lg p-6 max-w-[90vw] max-h-[90vh] w-full overflow-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-campfire-light">Кадрирование изображения</h3>
<div className="flex gap-2">
<button
onClick={handleCrop}
className="btn-primary flex items-center gap-2"
>
<FaCheck /> Применить
</button>
<button
onClick={onCancel}
className="btn-secondary flex items-center gap-2"
>
<FaTimes /> Отмена
</button>
</div>
</div>
<div
ref={containerRef}
className="relative bg-campfire-dark rounded-lg overflow-hidden mx-auto"
style={{
width: Math.min(imageSize.width, window.innerWidth * 0.8),
height: Math.min(imageSize.height, window.innerHeight * 0.6)
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
>
<img
ref={imageRef}
src={imageUrl}
alt="Для кадрирования"
className="max-w-full h-auto"
/>
{/* Область кадрирования */}
<div
className="absolute border-2 border-campfire-amber cursor-move"
style={{
left: crop.x,
top: crop.y,
width: crop.width,
height: crop.height
}}
>
{/* Сетка */}
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3">
{[...Array(4)].map((_, i) => (
<div
key={`v${i}`}
className="absolute top-0 bottom-0 w-px bg-campfire-amber/50"
style={{ left: `${(i * 100) / 3}%` }}
/>
))}
{[...Array(4)].map((_, i) => (
<div
key={`h${i}`}
className="absolute left-0 right-0 h-px bg-campfire-amber/50"
style={{ top: `${(i * 100) / 3}%` }}
/>
))}
</div>
</div>
{/* Затемнение вне области кадрирования */}
<div className="absolute inset-0 bg-black/50">
<div
className="absolute bg-transparent"
style={{
left: crop.x,
top: crop.y,
width: crop.width,
height: crop.height
}}
/>
</div>
</div>
<div className="mt-4 text-sm text-campfire-ash">
<p> Перетащите область кадрирования для выбора нужной части изображения</p>
<p> Используйте колесико мыши для изменения размера области кадрирования</p>
<p> Используйте сетку для точного позиционирования</p>
</div>
</div>
</div>
);
};
export default ImageCropper;

View File

@ -0,0 +1,31 @@
import React, { useState, useEffect } from 'react';
const LazyImage = ({ src, alt, className, fallbackSrc = 'https://images.pexels.com/photos/1704488/pexels-photo-1704488.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1' }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
const img = new Image();
img.src = src;
img.onload = () => setIsLoaded(true);
img.onerror = () => setError(true);
}, [src]);
return (
<div className={`relative ${className}`}>
{!isLoaded && !error && (
<div className="absolute inset-0 bg-campfire-charcoal animate-pulse" />
)}
<img
src={error ? fallbackSrc : src}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
loading="lazy"
/>
</div>
);
};
export default LazyImage;

View File

@ -0,0 +1,11 @@
import React from 'react';
const LoadingSpinner = () => {
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>
);
};
export default LoadingSpinner;

View File

@ -0,0 +1,26 @@
import React from 'react';
const MobileWarning = ({ onDismiss }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-campfire-dark p-4">
<div className="bg-campfire-charcoal rounded-lg shadow-xl p-6 max-w-md text-center">
<h2 className="text-2xl font-bold text-campfire-amber mb-4">Мобильная версия в разработке</h2>
<p className="text-campfire-light mb-6">
В настоящее время сайт оптимизирован только для десктопных устройств.
Для наилучшего опыта рекомендуем использовать компьютер или планшет.
</p>
<div className="text-campfire-light/70 text-sm mb-6">
Мы работаем над мобильной версией и скоро она будет доступна!
</div>
<button
onClick={onDismiss}
className="px-6 py-2 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/80 transition-colors font-medium"
>
Мне всё равно, перейти
</button>
</div>
</div>
);
};
export default MobileWarning;

View File

@ -0,0 +1,107 @@
import React, { useEffect, useRef, useState, memo } from 'react';
import PropTypes from 'prop-types';
const VirtualList = memo(function VirtualList({
items,
height,
itemHeight,
renderItem,
overscan = 3,
className = '',
onScroll,
initialScrollOffset = 0
}) {
const [scrollTop, setScrollTop] = useState(initialScrollOffset);
const [viewportHeight, setViewportHeight] = useState(height);
const containerRef = useRef(null);
// Вычисляем видимые элементы
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan
);
// Вычисляем смещение для видимых элементов
const offsetY = startIndex * itemHeight;
// Обработчик прокрутки
const handleScroll = (event) => {
const newScrollTop = event.target.scrollTop;
setScrollTop(newScrollTop);
onScroll?.(newScrollTop);
};
// Обновление высоты при изменении размера окна
useEffect(() => {
const updateHeight = () => {
if (containerRef.current) {
setViewportHeight(containerRef.current.clientHeight);
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, []);
// Установка начальной позиции прокрутки
useEffect(() => {
if (containerRef.current && initialScrollOffset > 0) {
containerRef.current.scrollTop = initialScrollOffset;
}
}, [initialScrollOffset]);
return (
<div
ref={containerRef}
className={`overflow-auto ${className}`}
style={{ height }}
onScroll={handleScroll}
>
<div
style={{
height: items.length * itemHeight,
position: 'relative'
}}
>
<div
style={{
position: 'absolute',
top: offsetY,
left: 0,
right: 0
}}
>
{items.slice(startIndex, endIndex + 1).map((item, index) => (
<div
key={item.id || index}
style={{
height: itemHeight,
position: 'absolute',
top: index * itemHeight,
left: 0,
right: 0
}}
>
{renderItem(item, startIndex + index)}
</div>
))}
</div>
</div>
</div>
);
});
VirtualList.propTypes = {
items: PropTypes.array.isRequired,
height: PropTypes.number.isRequired,
itemHeight: PropTypes.number.isRequired,
renderItem: PropTypes.func.isRequired,
overscan: PropTypes.number,
className: PropTypes.string,
onScroll: PropTypes.func,
initialScrollOffset: PropTypes.number
};
export default VirtualList;

View File

@ -0,0 +1,5 @@
export { default as Modal } from './Modal';
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Card } from './Card';
export { default as LoadingSpinner } from './LoadingSpinner';

View File

@ -3,7 +3,7 @@ import { Link, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useProfileActions } from '../../contexts/ProfileActionsContext';
import { getFileUrl } from '../../services/pocketbaseService';
import { FaUserCircle, FaSignOutAlt, FaEdit, FaBars, FaTimes, FaTachometerAlt, FaBook, FaTrophy, FaCog, FaHeadset, FaHome, FaUser } from 'react-icons/fa'; // Import FaBook, FaTrophy, FaCog, and FaHeadset
import { FaUserCircle, FaSignOutAlt, FaEdit, FaBars, FaTimes, FaTachometerAlt, FaBook, FaTrophy, FaCog, FaHeadset, FaHome, FaUser, FaBell } from 'react-icons/fa'; // Import FaBook, FaTrophy, FaCog, and FaHeadset
import { FiSearch } from 'react-icons/fi';
import SearchBar from '../ui/SearchBar';
import AlphaBadge from '../ui/AlphaBadge';
@ -12,9 +12,12 @@ import { canManageSystem } from '../../utils/permissions';
import Dock from '../reactbits/Components/Dock/Dock';
import '../reactbits/Components/Dock/Dock.css';
import { AnimatePresence, motion } from 'framer-motion';
import { pb } from '../../services/pocketbaseService';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
const Header = () => {
const { user, userProfile, signOut } = useAuth();
const { user, userProfile, logout } = useAuth();
const profileActions = useProfileActions();
const navigate = useNavigate();
const location = useLocation();
@ -25,6 +28,11 @@ const Header = () => {
const profileMenuRef = useRef(null);
const mobileMenuRef = useRef(null);
const searchContainerRef = useRef(null); // Ref for the search dropdown container
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const notificationsRef = useRef(null);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [logoError, setLogoError] = useState(false);
// Эффект для проверки доступности локального логотипа при монтировании
useEffect(() => {
@ -34,8 +42,8 @@ const Header = () => {
img.src = localLogo;
}, []); // Запускаем только один раз при монтировании
const handleExternalLogoError = () => {
console.log("External logo failed to load.");
const handleLogoError = () => {
setLogoError(true);
};
const toggleProfileMenu = () => {
@ -43,6 +51,13 @@ const Header = () => {
setIsMobileMenu(false); // Close mobile menu if profile opens
setIsSearchOpen(false); // Close search if profile opens
};
const toggleNotificationMenu = () => {
setIsNotificationsOpen(!isNotificationsOpen);
setIsProfileMenuOpen(false);
setIsMobileMenu(false);
setIsSearchOpen(false);
};
const toggleMobileMenu = () => {
setIsMobileMenu(!isMobileMenuOpen);
@ -92,17 +107,10 @@ const Header = () => {
}, []);
// Handle Edit Profile click from Header
const handleEditProfileClick = () => {
if (userProfile) {
console.log('Header: Edit Profile clicked. Navigating to profile and triggering modal.');
navigate(`/profile/${userProfile.username}`);
if (profileActions?.triggerEditModal) {
profileActions.triggerEditModal();
}
closeMenus();
} else {
console.log('Header: Edit Profile clicked, but userProfile is not available.');
}
const handleEditProfile = () => {
if (userProfile) {
navigate(`/profile/${userProfile.username}`, { state: { shouldOpenEditModal: true } });
}
};
const canAccessAdmin = canManageSystem(user);
@ -134,7 +142,7 @@ const Header = () => {
}
];
const profileItems = [
const profileItems = user ? [
{
icon: (
<div className="flex items-center gap-2 px-2">
@ -143,22 +151,104 @@ const Header = () => {
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>
<span className="text-[10px] text-campfire-light truncate max-w-[60px]">{userProfile?.login || 'Профиль'}</span>
</div>
),
onClick: () => navigate(userProfile ? `/profile/${userProfile.username}` : '/auth/login'),
onClick: () => navigate(`/profile/${userProfile?.login}`),
className: location.pathname.startsWith('/profile') ? 'bg-campfire-amber/20' : '',
showLabel: false,
isRectangular: true
},
{
icon: <FaBell className={`w-5 h-5 ${isNotificationsOpen ? 'text-campfire-amber' : 'text-campfire-light'}`} />,
onClick: toggleNotificationMenu,
className: isNotificationsOpen ? 'bg-campfire-amber/20' : '',
showLabel: false
},
{
icon: <FaBars className="w-5 h-5 text-campfire-light" />,
label: 'Меню',
onClick: toggleProfileMenu,
className: isProfileMenuOpen ? 'bg-campfire-amber/20' : ''
className: isProfileMenuOpen ? 'bg-campfire-amber/20' : '',
showLabel: false
}
] : [
{
icon: <FaUser className="w-5 h-5 text-campfire-light" />,
onClick: () => navigate('/auth/login'),
className: location.pathname === '/auth/login' ? 'bg-campfire-amber/20' : '',
showLabel: false
}
];
// Закрываем меню при изменении маршрута
useEffect(() => {
setIsProfileMenuOpen(false);
setIsNotificationsOpen(false);
}, [location]);
// Обработка клика вне меню
useEffect(() => {
const handleClickOutside = (event) => {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) {
setIsProfileMenuOpen(false);
}
if (notificationsRef.current && !notificationsRef.current.contains(event.target)) {
setIsNotificationsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
useEffect(() => {
if (user) {
loadNotifications();
}
}, [user]);
const loadNotifications = async () => {
try {
const records = await pb.collection('notifications').getList(1, 10, {
filter: `user_id = "${user.id}"`,
sort: '-created'
});
setNotifications(records.items);
setUnreadCount(records.items.filter(n => !n.is_read).length);
} catch (error) {
console.error('Error loading notifications:', error);
}
};
const markAsRead = async (notificationId) => {
try {
await pb.collection('notifications').update(notificationId, {
is_read: true
});
setNotifications(notifications.map(n =>
n.id === notificationId ? { ...n, is_read: true } : n
));
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (error) {
console.error('Error marking notification as read:', error);
}
};
// Прокрутка страницы вверх при изменении маршрута
useEffect(() => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}, [location.pathname]);
const handleLogout = () => {
logout();
navigate('/auth');
};
return (
<>
<header className="fixed top-2 left-0 right-0 z-50">
@ -170,7 +260,7 @@ const Header = () => {
src={logoSrc}
alt="CampFire мнение"
className="h-12"
onError={handleExternalLogoError}
onError={handleLogoError}
/>
</Link>
@ -220,24 +310,73 @@ const Header = () => {
spring={{ mass: 0.2, stiffness: 200, damping: 15 }}
/>
{/* Profile Dropdown Menu */}
{/* Notifications Dropdown */}
<AnimatePresence>
{isProfileMenuOpen && (
{isNotificationsOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
ref={notificationsRef}
className="absolute right-0 top-full mt-2 w-80 bg-campfire-charcoal rounded-md shadow-lg py-1 z-50 border border-campfire-ash/30"
>
<div className="p-4">
<h3 className="text-lg font-semibold text-campfire-light mb-4">Уведомления</h3>
{notifications.length === 0 ? (
<p className="text-campfire-light/70 text-center py-4">Нет новых уведомлений</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{notifications.map(notification => (
<div
key={notification.id}
className={`p-3 rounded-md cursor-pointer transition-colors ${
notification.is_read
? 'bg-campfire-ash/10 hover:bg-campfire-ash/20'
: 'bg-campfire-amber/10 hover:bg-campfire-amber/20'
}`}
onClick={() => markAsRead(notification.id)}
>
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium text-campfire-light">{notification.title}</h4>
<p className="text-sm text-campfire-light/70">{notification.message}</p>
</div>
<span className="text-xs text-campfire-light/50">
{formatDistanceToNow(new Date(notification.created), {
addSuffix: true,
locale: ru
})}
</span>
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* 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 }}
ref={profileMenuRef}
className="absolute right-0 top-full mt-2 w-48 bg-campfire-charcoal rounded-md shadow-lg py-1 z-50 border border-campfire-ash/30"
>
<Link
to={`/profile/${userProfile?.username}`}
onClick={closeMenus}
className="block px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaUserCircle />
<span>Мой профиль</span>
</Link>
<Link
to={`/profile/${userProfile?.login}`}
onClick={closeMenus}
className="block px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaUserCircle />
<span>Мой профиль</span>
</Link>
{canAccessAdmin && (
<Link
to="/admin"
@ -250,7 +389,7 @@ const Header = () => {
)}
{userProfile && (
<button
onClick={handleEditProfileClick}
onClick={handleEditProfile}
className="block w-full text-left px-4 py-2 text-sm text-campfire-light hover:bg-campfire-ash/20 flex items-center space-x-2"
>
<FaEdit />
@ -267,10 +406,7 @@ const Header = () => {
<span>Поддержка</span>
</Link>
<button
onClick={() => {
signOut();
closeMenus();
}}
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"
>
<FaSignOutAlt />
@ -279,7 +415,7 @@ const Header = () => {
</>
)}
</motion.div>
)}
)}
</AnimatePresence>
</div>
@ -343,10 +479,7 @@ const Header = () => {
</NavLink>
{user && (
<button
onClick={() => {
signOut();
closeMenus();
}}
onClick={handleLogout}
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 />
@ -368,16 +501,28 @@ const Header = () => {
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 className="flex justify-between items-center">
<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 }}
/>
<Dock
items={profileItems}
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>
</div>
</motion.div>

View File

@ -5,30 +5,27 @@ 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
const Layout = () => {
const { loading, isInitialized } = useAuth();
// 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>
);
if (!isInitialized || loading) {
return (
<div className="min-h-screen bg-campfire-dark flex items-center justify-center">
<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">
<div className="min-h-screen bg-campfire-dark flex flex-col">
<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 className="flex-grow">
<Outlet />
</main>
<Footer />
</div>
);
}
};
export default Layout;

View File

@ -1,13 +1,15 @@
import React, { memo } from 'react';
import { Link } from 'react-router-dom';
import { FaFire } from 'react-icons/fa'; // Changed FaStar to FaFire
import { getFileUrl } from '../../services/pocketbaseService'; // Corrected import path
import { useEffect } from 'react'; // Import useEffect for logging
import { useAuth } from '../../contexts/AuthContext'; // Import useAuth
import LazyImage from '../common/LazyImage';
// 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 }) {
const MediaCard = memo(function MediaCard({ media, userProfile }) {
// Destructure media properties, including the new ones from Supabase
// Use 'path' for the link instead of 'id'
// Используем 'poster' вместо 'poster_url' для имени файла
@ -42,11 +44,10 @@ function MediaCard({ media, userProfile }) {
<div className="card group h-full bg-campfire-charcoal rounded-lg overflow-hidden shadow-lg border border-campfire-ash/20 transition-all duration-300 hover:shadow-xl hover:border-campfire-amber/30 relative"> {/* Added relative for absolute positioning */}
<div className="relative overflow-hidden aspect-[2/3]">
{/* Используем posterUrl, полученный через getFileUrl */}
<img
<LazyImage
src={posterUrl}
alt={title}
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-300"
loading="lazy"
className="w-full h-full transform group-hover:scale-105 transition-transform duration-300"
/>
{/* Display average rating and review count */}
{/* Проверяем, что average_rating не null и не undefined перед отображением */}
@ -84,6 +85,6 @@ function MediaCard({ media, userProfile }) {
</div>
</Link>
);
}
});
export default MediaCard;

View File

@ -1,30 +1,53 @@
import React from 'react';
import TiltedCard from '../components/reactbits/Components/TiltedCard/TiltedCard';
import React, { memo, useCallback } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import MediaCard from './MediaCard';
// Simple Media Carousel component
const MediaCarousel = ({ media, userProfile }) => {
if (!media || media.length === 0) {
return <div className="text-campfire-ash text-center">Нет контента для отображения.</div>;
}
const MediaCarousel = memo(function MediaCarousel({ title, media, userProfile }) {
const scrollContainerRef = React.useRef(null);
const scroll = useCallback((direction) => {
if (scrollContainerRef.current) {
const scrollAmount = direction === 'left' ? -400 : 400;
scrollContainerRef.current.scrollBy({
left: scrollAmount,
behavior: 'smooth'
});
}
}, []);
return (
// Use flexbox for horizontal layout and overflow-x-auto for scrolling
// Add padding-bottom to prevent scrollbar from covering content
<div className="flex overflow-x-auto space-x-8 pb-4 scrollbar-thin scrollbar-thumb-campfire-amber scrollbar-track-campfire-charcoal">
{media.map((mediaItem) => (
// Wrap MediaCard in a div with flex-shrink-0 to prevent shrinking
// Set a fixed width for each card in the carousel
<div key={mediaItem.id} className="flex-shrink-0 w-40 sm:w-48 md:w-56 lg:w-64"> {/* Adjust width as needed */}
<TiltedCard
imageSrc={mediaItem.poster_path}
captionText={mediaItem.title}
rating={mediaItem.rating}
releaseDate={mediaItem.release_date}
/>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-campfire-light">{title}</h2>
<div className="flex space-x-2">
<button
onClick={() => scroll('left')}
className="p-2 rounded-full bg-campfire-charcoal text-campfire-light hover:bg-campfire-amber hover:text-campfire-dark transition-colors"
>
<FaChevronLeft />
</button>
<button
onClick={() => scroll('right')}
className="p-2 rounded-full bg-campfire-charcoal text-campfire-light hover:bg-campfire-amber hover:text-campfire-dark transition-colors"
>
<FaChevronRight />
</button>
</div>
))}
</div>
<div
ref={scrollContainerRef}
className="flex space-x-4 overflow-x-auto pb-4 scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{media.map((item) => (
<div key={item.id} className="flex-shrink-0 w-[200px]">
<MediaCard media={item} userProfile={userProfile} />
</div>
))}
</div>
</div>
);
};
});
export default MediaCarousel;

View File

@ -0,0 +1,59 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { FaArrowLeft, FaFire } from 'react-icons/fa';
const MediaHeader = ({ media }) => {
const backdropUrl = media.backdrop ? `url(${media.backdrop})` : 'none';
const posterUrl = media.poster ? `url(${media.poster})` : 'none';
return (
<div className="relative">
{/* Backdrop */}
<div
className="relative h-[500px] w-full"
style={{
backgroundImage: `url(${getFileUrl(media, 'backdrop')})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark via-campfire-dark/80 to-transparent"></div>
{/* Content */}
<div className="container-custom relative z-10 h-full flex items-end pb-12">
<div className="flex items-center">
{/* Poster */}
<img
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>
<div className="flex items-center text-campfire-ash text-lg mb-4">
<span className="text-campfire-amber mr-4 text-2xl font-bold">
{media.average_rating ? parseFloat(media.average_rating).toFixed(1) : 'N/A'} / 10
</span>
<FaFire className="text-campfire-amber mr-4 text-xl" />
<span className="text-campfire-ash text-lg mr-4">
{media.review_count || 0} рецензий
</span>
{media.release_date && (
<span className="text-campfire-ash text-lg">
Дата выхода: {new Date(media.release_date).toLocaleDateString()}
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default MediaHeader;

View File

@ -1,18 +1,19 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, memo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getFileUrl } from '../../utils/fileUtils';
import LazyImage from '../common/LazyImage';
const ProfileMenu = ({ isOpen, onClose, user, onSignOut }) => {
const ProfileMenu = memo(function 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();
}
};
const handleClickOutside = useCallback((event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
@ -20,7 +21,17 @@ const ProfileMenu = ({ isOpen, onClose, user, onSignOut }) => {
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
}, [isOpen, handleClickOutside]);
const handleProfileClick = useCallback(() => {
navigate(`/profile/${user.login}`);
onClose();
}, [navigate, user.login, onClose]);
const handleSignOut = useCallback(() => {
onSignOut();
onClose();
}, [onSignOut, onClose]);
if (!isOpen) return null;
@ -33,10 +44,10 @@ const ProfileMenu = ({ isOpen, onClose, user, onSignOut }) => {
>
<div className="p-4 border-b border-campfire-gold/20">
<div className="flex items-center space-x-3">
<img
<LazyImage
src={getFileUrl(user, 'profile_picture', { thumb: '100x100' }) || '/default-avatar.png'}
alt={user.username}
className="w-10 h-10 rounded-full object-cover"
className="w-10 h-10 rounded-full"
/>
<div>
<h3 className="text-campfire-light font-semibold">{user.username}</h3>
@ -46,19 +57,13 @@ const ProfileMenu = ({ isOpen, onClose, user, onSignOut }) => {
</div>
<div className="py-2">
<button
onClick={() => {
navigate(`/profile/${user.username}`);
onClose();
}}
onClick={handleProfileClick}
className="w-full px-4 py-2 text-left text-campfire-light hover:bg-campfire-gold/10 transition-colors"
>
Профиль
</button>
<button
onClick={() => {
onSignOut();
onClose();
}}
onClick={handleSignOut}
className="w-full px-4 py-2 text-left text-campfire-light hover:bg-campfire-gold/10 transition-colors"
>
Выйти
@ -67,6 +72,6 @@ const ProfileMenu = ({ isOpen, onClose, user, onSignOut }) => {
</div>
</div>
);
};
});
export default ProfileMenu;

View File

@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
import { pb } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
import { FaBell, FaTrophy, FaHeart, FaComment, FaCog, FaLevelUpAlt } from 'react-icons/fa';
const NotificationCenter = () => {
const { user } = useAuth();
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [settings, setSettings] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
if (user) {
loadNotifications();
loadSettings();
// Подписываемся на новые уведомления
try {
const subscription = pb.collection('notifications').subscribe('*', () => {
loadNotifications();
});
// Возвращаем функцию очистки
return () => {
if (subscription && typeof subscription.unsubscribe === 'function') {
subscription.unsubscribe();
}
};
} catch (err) {
console.error('Error subscribing to notifications:', err);
setError('Не удалось подписаться на уведомления');
}
}
}, [user]);
const loadNotifications = async () => {
try {
const records = await pb.collection('notifications').getList(1, 50, {
filter: `user_id = "${user.id}"`,
sort: '-created',
expand: 'user_id'
});
setNotifications(records.items);
setUnreadCount(records.items.filter(n => !n.is_read).length);
setError(null);
} catch (error) {
console.error('Error loading notifications:', error);
setError('Не удалось загрузить уведомления');
}
};
const loadSettings = async () => {
try {
const record = await pb.collection('notification_settings').getFirstListItem(`user_id = "${user.id}"`);
setSettings(record);
setError(null);
} catch (error) {
if (error.status === 404) {
try {
// Создаем настройки по умолчанию
const defaultSettings = {
user_id: user.id,
achievement: true,
like: true,
review_comment: true,
system: true,
level_up: true,
email_notifications: false
};
const newSettings = await pb.collection('notification_settings').create(defaultSettings);
setSettings(newSettings);
setError(null);
} catch (createError) {
console.error('Error creating notification settings:', createError);
setError('Не удалось создать настройки уведомлений');
}
} else {
console.error('Error loading notification settings:', error);
setError('Не удалось загрузить настройки уведомлений');
}
}
};
const markAsRead = async (notificationId) => {
try {
await pb.collection('notifications').update(notificationId, { is_read: true });
loadNotifications();
} catch (error) {
console.error('Error marking notification as read:', error);
}
};
const markAllAsRead = async () => {
try {
const unreadNotifications = notifications.filter(n => !n.is_read);
await Promise.all(
unreadNotifications.map(n =>
pb.collection('notifications').update(n.id, { is_read: true })
)
);
loadNotifications();
} catch (error) {
console.error('Error marking all notifications as read:', error);
}
};
const updateSettings = async (setting, value) => {
try {
await pb.collection('notification_settings').update(settings.id, {
[setting]: value
});
setSettings(prev => ({ ...prev, [setting]: value }));
} catch (error) {
console.error('Error updating notification settings:', error);
}
};
const getNotificationIcon = (type) => {
switch (type) {
case 'achievement':
return <FaTrophy className="text-campfire-amber" />;
case 'like':
return <FaHeart className="text-campfire-amber" />;
case 'review_comment':
return <FaComment className="text-campfire-amber" />;
case 'level_up':
return <FaLevelUpAlt className="text-campfire-amber" />;
default:
return <FaCog className="text-campfire-amber" />;
}
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 rounded-full hover:bg-campfire-charcoal/20 transition-colors"
>
<FaBell className="text-campfire-light" />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 w-4 h-4 bg-campfire-amber rounded-full text-xs flex items-center justify-center text-campfire-dark">
{unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-campfire-dark rounded-lg shadow-lg border border-campfire-ash/20 z-50">
<div className="p-4 border-b border-campfire-ash/20">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-campfire-light">Уведомления</h3>
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-sm text-campfire-amber hover:underline"
>
Отметить все как прочитанные
</button>
)}
</div>
</div>
{error && (
<div className="p-4 text-center text-red-500 text-sm">
{error}
</div>
)}
<div className="max-h-96 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map(notification => (
<div
key={notification.id}
className={`p-4 border-b border-campfire-ash/20 hover:bg-campfire-charcoal/20 transition-colors ${
!notification.is_read ? 'bg-campfire-charcoal/10' : ''
}`}
onClick={() => markAsRead(notification.id)}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-1">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-grow">
<h4 className="text-sm font-semibold text-campfire-light">
{notification.title}
</h4>
<p className="text-sm text-campfire-ash mt-1">
{notification.message}
</p>
<p className="text-xs text-campfire-ash/60 mt-2">
{new Date(notification.created).toLocaleString()}
</p>
</div>
</div>
</div>
))
) : (
<div className="p-4 text-center text-campfire-ash">
Нет уведомлений
</div>
)}
</div>
<div className="p-4 border-t border-campfire-ash/20">
<h4 className="text-sm font-semibold text-campfire-light mb-3">
Настройки уведомлений
</h4>
{settings && (
<div className="space-y-2">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={settings.achievement}
onChange={(e) => updateSettings('achievement', 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>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={settings.like}
onChange={(e) => updateSettings('like', 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>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={settings.review_comment}
onChange={(e) => updateSettings('review_comment', 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>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={settings.level_up}
onChange={(e) => updateSettings('level_up', 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>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={settings.system}
onChange={(e) => updateSettings('system', 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>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={settings.email_notifications}
onChange={(e) => updateSettings('email_notifications', e.target.checked)}
className="rounded border-campfire-ash/30 text-campfire-amber focus:ring-campfire-amber"
/>
<span className="text-sm text-campfire-light">Email-уведомления</span>
</label>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default NotificationCenter;

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

@ -22,6 +22,7 @@ const ClickSpark = forwardRef(({
addSpark: (x, y) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const sparkX = x - rect.left;
const sparkY = y - rect.top;
@ -34,7 +35,7 @@ const ClickSpark = forwardRef(({
startTime: now,
}));
sparksRef.current.push(...newSparks);
sparksRef.current = [...sparksRef.current, ...newSparks];
}
}));
@ -156,4 +157,6 @@ const ClickSpark = forwardRef(({
);
});
ClickSpark.displayName = 'ClickSpark';
export default ClickSpark;

View File

@ -0,0 +1,152 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useRef, useEffect, useState } from 'react';
import { gsap } from 'gsap';
function PixelTransition({
firstContent,
secondContent,
gridSize = 50,
pixelColor = 'currentColor',
animationStepDuration = 0.3,
className = '',
style = {},
aspectRatio = '10%',
}) {
const containerRef = useRef(null);
const pixelGridRef = useRef(null);
const activeRef = useRef(null);
const delayedCallRef = useRef(null);
const [isActive, setIsActive] = useState(false);
const isTouchDevice =
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
window.matchMedia('(pointer: coarse)').matches;
useEffect(() => {
const pixelGridEl = pixelGridRef.current;
if (!pixelGridEl) return;
pixelGridEl.innerHTML = '';
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < gridSize; col++) {
const pixel = document.createElement('div');
pixel.classList.add('pixelated-image-card__pixel');
pixel.classList.add('absolute', 'hidden');
pixel.style.backgroundColor = pixelColor;
const size = 100 / gridSize;
pixel.style.width = `${size}%`;
pixel.style.height = `${size}%`;
pixel.style.left = `${col * size}%`;
pixel.style.top = `${row * size}%`;
pixelGridEl.appendChild(pixel);
}
}
}, [gridSize, pixelColor]);
const animatePixels = (activate) => {
setIsActive(activate);
const pixelGridEl = pixelGridRef.current;
const activeEl = activeRef.current;
if (!pixelGridEl || !activeEl) return;
const pixels = pixelGridEl.querySelectorAll('.pixelated-image-card__pixel');
if (!pixels.length) return;
gsap.killTweensOf(pixels);
if (delayedCallRef.current) {
delayedCallRef.current.kill();
}
gsap.set(pixels, { display: 'none' });
const totalPixels = pixels.length;
const staggerDuration = animationStepDuration / totalPixels;
gsap.to(pixels, {
display: 'block',
duration: 0,
stagger: {
each: staggerDuration,
from: 'random'
}
});
delayedCallRef.current = gsap.delayedCall(animationStepDuration, () => {
activeEl.style.display = activate ? 'block' : 'none';
activeEl.style.pointerEvents = activate ? 'none' : '';
});
gsap.to(pixels, {
display: 'none',
duration: 0,
delay: animationStepDuration,
stagger: {
each: staggerDuration,
from: 'random'
}
});
};
const handleMouseEnter = () => {
if (!isActive) animatePixels(true);
};
const handleMouseLeave = () => {
if (isActive) animatePixels(false);
};
const handleClick = () => {
animatePixels(!isActive);
};
return (
<div
ref={containerRef}
// Combine your own className with the Tailwind classes for styling
className={`
${className}
bg-[#222]
text-white
rounded-[15px]
border-2
border-white
w-[300px]
max-w-full
relative
overflow-hidden
`}
style={style}
onMouseEnter={!isTouchDevice ? handleMouseEnter : undefined}
onMouseLeave={!isTouchDevice ? handleMouseLeave : undefined}
onClick={isTouchDevice ? handleClick : undefined}
>
<div style={{ paddingTop: aspectRatio }} />
<div className="absolute inset-0 w-full h-full">
{firstContent}
</div>
<div
ref={activeRef}
className="absolute inset-0 w-full h-full z-[2]"
style={{ display: 'none' }}
>
{secondContent}
</div>
<div
ref={pixelGridRef}
className="absolute inset-0 w-full h-full pointer-events-none z-[3]"
/>
</div>
);
}
export default PixelTransition;

View File

@ -0,0 +1,295 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
/* eslint-disable react/no-unknown-property */
import { useRef, useState, useEffect } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { EffectComposer, wrapEffect } from "@react-three/postprocessing";
import { Effect } from "postprocessing";
import * as THREE from "three";
const waveVertexShader = `
precision highp float;
varying vec2 vUv;
void main() {
vUv = uv;
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
gl_Position = projectionMatrix * viewPosition;
}
`;
const waveFragmentShader = `
precision highp float;
uniform vec2 resolution;
uniform float time;
uniform float waveSpeed;
uniform float waveFrequency;
uniform float waveAmplitude;
uniform vec3 waveColor;
uniform vec2 mousePos;
uniform int enableMouseInteraction;
uniform float mouseRadius;
vec4 mod289(vec4 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
vec2 fade(vec2 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); }
float cnoise(vec2 P) {
vec4 Pi = floor(P.xyxy) + vec4(0.0,0.0,1.0,1.0);
vec4 Pf = fract(P.xyxy) - vec4(0.0,0.0,1.0,1.0);
Pi = mod289(Pi);
vec4 ix = Pi.xzxz;
vec4 iy = Pi.yyww;
vec4 fx = Pf.xzxz;
vec4 fy = Pf.yyww;
vec4 i = permute(permute(ix) + iy);
vec4 gx = fract(i * (1.0/41.0)) * 2.0 - 1.0;
vec4 gy = abs(gx) - 0.5;
vec4 tx = floor(gx + 0.5);
gx = gx - tx;
vec2 g00 = vec2(gx.x, gy.x);
vec2 g10 = vec2(gx.y, gy.y);
vec2 g01 = vec2(gx.z, gy.z);
vec2 g11 = vec2(gx.w, gy.w);
vec4 norm = taylorInvSqrt(vec4(dot(g00,g00), dot(g01,g01), dot(g10,g10), dot(g11,g11)));
g00 *= norm.x; g01 *= norm.y; g10 *= norm.z; g11 *= norm.w;
float n00 = dot(g00, vec2(fx.x, fy.x));
float n10 = dot(g10, vec2(fx.y, fy.y));
float n01 = dot(g01, vec2(fx.z, fy.z));
float n11 = dot(g11, vec2(fx.w, fy.w));
vec2 fade_xy = fade(Pf.xy);
vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
return 2.3 * mix(n_x.x, n_x.y, fade_xy.y);
}
const int OCTAVES = 8;
float fbm(vec2 p) {
float value = 0.0;
float amp = 1.0;
float freq = waveFrequency;
for (int i = 0; i < OCTAVES; i++) {
value += amp * abs(cnoise(p));
p *= freq;
amp *= waveAmplitude;
}
return value;
}
float pattern(vec2 p) {
vec2 p2 = p - time * waveSpeed;
return fbm(p - fbm(p + fbm(p2)));
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
uv -= 0.5;
uv.x *= resolution.x / resolution.y;
float f = pattern(uv);
if (enableMouseInteraction == 1) {
vec2 mouseNDC = (mousePos / resolution - 0.5) * vec2(1.0, -1.0);
mouseNDC.x *= resolution.x / resolution.y;
float dist = length(uv - mouseNDC);
float effect = 1.0 - smoothstep(0.0, mouseRadius, dist);
f -= 0.5 * effect;
}
vec3 col = mix(vec3(0.0), waveColor, f);
gl_FragColor = vec4(col, 1.0);
}
`;
const ditherFragmentShader = `
precision highp float;
uniform float colorNum;
uniform float pixelSize;
const float bayerMatrix8x8[64] = float[64](
0.0/64.0, 48.0/64.0, 12.0/64.0, 60.0/64.0, 3.0/64.0, 51.0/64.0, 15.0/64.0, 63.0/64.0,
32.0/64.0,16.0/64.0, 44.0/64.0, 28.0/64.0, 35.0/64.0,19.0/64.0, 47.0/64.0, 31.0/64.0,
8.0/64.0, 56.0/64.0, 4.0/64.0, 52.0/64.0, 11.0/64.0,59.0/64.0, 7.0/64.0, 55.0/64.0,
40.0/64.0,24.0/64.0, 36.0/64.0, 20.0/64.0, 43.0/64.0,27.0/64.0, 39.0/64.0, 23.0/64.0,
2.0/64.0, 50.0/64.0, 14.0/64.0, 62.0/64.0, 1.0/64.0,49.0/64.0, 13.0/64.0, 61.0/64.0,
34.0/64.0,18.0/64.0, 46.0/64.0, 30.0/64.0, 33.0/64.0,17.0/64.0, 45.0/64.0, 29.0/64.0,
10.0/64.0,58.0/64.0, 6.0/64.0, 54.0/64.0, 9.0/64.0,57.0/64.0, 5.0/64.0, 53.0/64.0,
42.0/64.0,26.0/64.0, 38.0/64.0, 22.0/64.0, 41.0/64.0,25.0/64.0, 37.0/64.0, 21.0/64.0
);
vec3 dither(vec2 uv, vec3 color) {
vec2 scaledCoord = floor(uv * resolution / pixelSize);
int x = int(mod(scaledCoord.x, 8.0));
int y = int(mod(scaledCoord.y, 8.0));
float threshold = bayerMatrix8x8[y * 8 + x] - 0.25;
float step = 1.0 / (colorNum - 1.0);
color += threshold * step;
float bias = 0.2;
color = clamp(color - bias, 0.0, 1.0);
return floor(color * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
}
void mainImage(in vec4 inputColor, in vec2 uv, out vec4 outputColor) {
vec2 normalizedPixelSize = pixelSize / resolution;
vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize);
vec4 color = texture2D(inputBuffer, uvPixel);
color.rgb = dither(uv, color.rgb);
outputColor = color;
}
`;
class RetroEffectImpl extends Effect {
constructor() {
const uniforms = new Map([
["colorNum", new THREE.Uniform(4.0)],
["pixelSize", new THREE.Uniform(2.0)]
]);
super("RetroEffect", ditherFragmentShader, { uniforms });
this.uniforms = uniforms;
}
set colorNum(value) {
this.uniforms.get("colorNum").value = value;
}
get colorNum() {
return this.uniforms.get("colorNum").value;
}
set pixelSize(value) {
this.uniforms.get("pixelSize").value = value;
}
get pixelSize() {
return this.uniforms.get("pixelSize").value;
}
}
const RetroEffect = wrapEffect(RetroEffectImpl);
function DitheredWaves({
waveSpeed,
waveFrequency,
waveAmplitude,
waveColor,
colorNum,
pixelSize,
disableAnimation,
enableMouseInteraction,
mouseRadius
}) {
const mesh = useRef(null);
const effect = useRef(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const { viewport, size, gl } = useThree();
const waveUniformsRef = useRef({
time: { value: 0 },
resolution: { value: new THREE.Vector2(0, 0) },
waveSpeed: { value: waveSpeed },
waveFrequency: { value: waveFrequency },
waveAmplitude: { value: waveAmplitude },
waveColor: { value: new THREE.Color(...waveColor) },
mousePos: { value: new THREE.Vector2(0, 0) },
enableMouseInteraction: { value: enableMouseInteraction ? 1 : 0 },
mouseRadius: { value: mouseRadius }
});
useEffect(() => {
const dpr = gl.getPixelRatio();
const newWidth = Math.floor(size.width * dpr);
const newHeight = Math.floor(size.height * dpr);
const currentRes = waveUniformsRef.current.resolution.value;
if (currentRes.x !== newWidth || currentRes.y !== newHeight) {
currentRes.set(newWidth, newHeight);
if (
effect.current &&
effect.current.uniforms &&
effect.current.uniforms.resolution &&
effect.current.uniforms.resolution.value
) {
effect.current.uniforms.resolution.value.set(newWidth, newHeight);
}
}
}, [size, gl]);
useFrame(({ clock }) => {
if (!disableAnimation) {
waveUniformsRef.current.time.value = clock.getElapsedTime();
}
waveUniformsRef.current.waveSpeed.value = waveSpeed;
waveUniformsRef.current.waveFrequency.value = waveFrequency;
waveUniformsRef.current.waveAmplitude.value = waveAmplitude;
waveUniformsRef.current.waveColor.value.set(...waveColor);
waveUniformsRef.current.enableMouseInteraction.value = enableMouseInteraction ? 1 : 0;
waveUniformsRef.current.mouseRadius.value = mouseRadius;
if (enableMouseInteraction) {
waveUniformsRef.current.mousePos.value.set(mousePos.x, mousePos.y);
}
if (effect.current) {
effect.current.colorNum = colorNum;
effect.current.pixelSize = pixelSize;
}
});
const handlePointerMove = (e) => {
if (!enableMouseInteraction) return;
const rect = gl.domElement.getBoundingClientRect();
const dpr = gl.getPixelRatio();
const x = (e.clientX - rect.left) * dpr;
const y = (e.clientY - rect.top) * dpr;
setMousePos({ x, y });
};
return (
<>
<mesh ref={mesh} scale={[viewport.width, viewport.height, 1]}>
<planeGeometry args={[1, 1]} />
<shaderMaterial
vertexShader={waveVertexShader}
fragmentShader={waveFragmentShader}
uniforms={waveUniformsRef.current}
/>
</mesh>
<EffectComposer>
<RetroEffect ref={effect} />
</EffectComposer>
<mesh
onPointerMove={handlePointerMove}
position={[0, 0, 0.01]}
scale={[viewport.width, viewport.height, 1]}
visible={false}
>
<planeGeometry args={[1, 1]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
</>
);
}
export default function Dither({
waveSpeed = 0.05,
waveFrequency = 3,
waveAmplitude = 0.3,
waveColor = [0.5, 0.5, 0.5],
colorNum = 4,
pixelSize = 2,
disableAnimation = false,
enableMouseInteraction = true,
mouseRadius = 1
}) {
return (
<Canvas
className="w-full h-full relative"
camera={{ position: [0, 0, 6] }}
dpr={window.devicePixelRatio}
gl={{ antialias: true, preserveDrawingBuffer: true }}
>
<DitheredWaves
waveSpeed={waveSpeed}
waveFrequency={waveFrequency}
waveAmplitude={waveAmplitude}
waveColor={waveColor}
colorNum={colorNum}
pixelSize={pixelSize}
disableAnimation={disableAnimation}
enableMouseInteraction={enableMouseInteraction}
mouseRadius={mouseRadius}
/>
</Canvas>
);
}

View File

@ -65,7 +65,7 @@ const GridMotion = ({ items = [], gradientColor = 'black' }) => {
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]"
className="gap-4 flex-none relative w-[120vw] h-[200vh] grid grid-rows-4 grid-cols-1 rotate-[-15deg] origin-center z-[2]"
>
{[...Array(4)].map((_, rowIndex) => (
<div

View File

@ -0,0 +1,255 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useRef, useEffect } from 'react';
import * as THREE from 'three';
const vertexShader = /* glsl */`
varying vec2 v_texcoord;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
v_texcoord = uv;
}
`;
const fragmentShader = /* glsl */`
varying vec2 v_texcoord;
uniform vec2 u_mouse;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform float u_shapeSize;
uniform float u_roundness;
uniform float u_borderSize;
uniform float u_circleSize;
uniform float u_circleEdge;
#ifndef PI
#define PI 3.1415926535897932384626433832795
#endif
#ifndef TWO_PI
#define TWO_PI 6.2831853071795864769252867665590
#endif
#ifndef VAR
#define VAR 0
#endif
#ifndef FNC_COORD
#define FNC_COORD
vec2 coord(in vec2 p) {
p = p / u_resolution.xy;
if (u_resolution.x > u_resolution.y) {
p.x *= u_resolution.x / u_resolution.y;
p.x += (u_resolution.y - u_resolution.x) / u_resolution.y / 2.0;
} else {
p.y *= u_resolution.y / u_resolution.x;
p.y += (u_resolution.x - u_resolution.y) / u_resolution.x / 2.0;
}
p -= 0.5;
p *= vec2(-1.0, 1.0);
return p;
}
#endif
#define st0 coord(gl_FragCoord.xy)
#define mx coord(u_mouse * u_pixelRatio)
float sdRoundRect(vec2 p, vec2 b, float r) {
vec2 d = abs(p - 0.5) * 4.2 - b + vec2(r);
return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - r;
}
float sdCircle(in vec2 st, in vec2 center) {
return length(st - center) * 2.0;
}
float sdPoly(in vec2 p, in float w, in int sides) {
float a = atan(p.x, p.y) + PI;
float r = TWO_PI / float(sides);
float d = cos(floor(0.5 + a / r) * r - a) * length(max(abs(p) * 1.0, 0.0));
return d * 2.0 - w;
}
float aastep(float threshold, float value) {
float afwidth = length(vec2(dFdx(value), dFdy(value))) * 0.70710678118654757;
return smoothstep(threshold - afwidth, threshold + afwidth, value);
}
float fill(in float x) { return 1.0 - aastep(0.0, x); }
float fill(float x, float size, float edge) {
return 1.0 - smoothstep(size - edge, size + edge, x);
}
float stroke(in float d, in float t) { return (1.0 - aastep(t, abs(d))); }
float stroke(float x, float size, float w, float edge) {
float d = smoothstep(size - edge, size + edge, x + w * 0.5) - smoothstep(size - edge, size + edge, x - w * 0.5);
return clamp(d, 0.0, 1.0);
}
float strokeAA(float x, float size, float w, float edge) {
float afwidth = length(vec2(dFdx(x), dFdy(x))) * 0.70710678;
float d = smoothstep(size - edge - afwidth, size + edge + afwidth, x + w * 0.5)
- smoothstep(size - edge - afwidth, size + edge + afwidth, x - w * 0.5);
return clamp(d, 0.0, 1.0);
}
void main() {
vec2 st = st0 + 0.5;
vec2 posMouse = mx * vec2(1., -1.) + 0.5;
float size = u_shapeSize;
float roundness = u_roundness;
float borderSize = u_borderSize;
float circleSize = u_circleSize;
float circleEdge = u_circleEdge;
float sdfCircle = fill(
sdCircle(st, posMouse),
circleSize,
circleEdge
);
float sdf;
if (VAR == 0) {
sdf = sdRoundRect(st, vec2(size), roundness);
sdf = strokeAA(sdf, 0.0, borderSize, sdfCircle) * 4.0;
} else if (VAR == 1) {
sdf = sdCircle(st, vec2(0.5));
sdf = fill(sdf, 0.6, sdfCircle) * 1.2;
} else if (VAR == 2) {
sdf = sdCircle(st, vec2(0.5));
sdf = strokeAA(sdf, 0.58, 0.02, sdfCircle) * 4.0;
} else if (VAR == 3) {
sdf = sdPoly(st - vec2(0.5, 0.45), 0.3, 3);
sdf = fill(sdf, 0.05, sdfCircle) * 1.4;
}
vec3 color = vec3(sdf);
float alpha = step(0.01, sdf);
gl_FragColor = vec4(color.rgb, alpha);
}
`;
const ShapeBlur = ({
className = '',
variation = 0,
pixelRatioProp = 2,
shapeSize = 1.2,
roundness = 0.4,
borderSize = 0.05,
circleSize = 0.3,
circleEdge = 0.5
}) => {
const mountRef = useRef();
useEffect(() => {
const mount = mountRef.current;
let animationFrameId;
let time = 0, lastTime = 0;
const vMouse = new THREE.Vector2();
const vMouseDamp = new THREE.Vector2();
const vResolution = new THREE.Vector2();
let w = 1, h = 1;
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera();
camera.position.z = 1;
const renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setClearColor(0x000000, 0);
mount.appendChild(renderer.domElement);
const geo = new THREE.PlaneGeometry(1, 1);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
u_mouse: { value: vMouseDamp },
u_resolution: { value: vResolution },
u_pixelRatio: { value: pixelRatioProp },
u_shapeSize: { value: shapeSize },
u_roundness: { value: roundness },
u_borderSize: { value: borderSize },
u_circleSize: { value: circleSize },
u_circleEdge: { value: circleEdge }
},
defines: { VAR: variation },
transparent: true
});
const quad = new THREE.Mesh(geo, material);
scene.add(quad);
const onPointerMove = (e) => {
const rect = mount.getBoundingClientRect();
vMouse.set(e.clientX - rect.left, e.clientY - rect.top);
};
document.addEventListener('mousemove', onPointerMove);
document.addEventListener('pointermove', onPointerMove);
const resize = () => {
const container = mountRef.current;
w = container.clientWidth;
h = container.clientHeight;
const dpr = Math.min(window.devicePixelRatio, 2);
renderer.setSize(w, h);
renderer.setPixelRatio(dpr);
camera.left = -w / 2;
camera.right = w / 2;
camera.top = h / 2;
camera.bottom = -h / 2;
camera.updateProjectionMatrix();
quad.scale.set(w, h, 1);
vResolution.set(w, h).multiplyScalar(dpr);
material.uniforms.u_pixelRatio.value = dpr;
};
resize();
window.addEventListener('resize', resize);
const ro = new ResizeObserver(() => resize());
if (mountRef.current) ro.observe(mountRef.current);
const update = () => {
time = performance.now() * 0.001;
const dt = time - lastTime;
lastTime = time;
['x', 'y'].forEach(k => {
vMouseDamp[k] = THREE.MathUtils.damp(vMouseDamp[k], vMouse[k], 8, dt);
});
renderer.render(scene, camera);
animationFrameId = requestAnimationFrame(update);
};
update();
return () => {
cancelAnimationFrame(animationFrameId);
window.removeEventListener('resize', resize);
if (ro) ro.disconnect();
document.removeEventListener('mousemove', onPointerMove);
document.removeEventListener('pointermove', onPointerMove);
mount.removeChild(renderer.domElement);
renderer.dispose();
};
}, [
variation,
pixelRatioProp,
shapeSize,
roundness,
borderSize,
circleSize,
circleEdge
]);
return <div ref={mountRef} className={`w-full h-full ${className}`} />;
};
export default ShapeBlur;

View File

@ -0,0 +1,160 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useRef, useEffect } from "react";
const Squares = ({
direction = "right",
speed = 1,
borderColor = "#999",
squareSize = 40,
hoverFillColor = "#222",
}) => {
const canvasRef = useRef(null);
const requestRef = useRef(null);
const numSquaresX = useRef(0);
const numSquaresY = useRef(0);
const gridOffset = useRef({ x: 0, y: 0 });
const hoveredSquareRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const resizeCanvas = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
numSquaresX.current = Math.ceil(canvas.width / squareSize) + 1;
numSquaresY.current = Math.ceil(canvas.height / squareSize) + 1;
};
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const drawGrid = () => {
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize;
const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize;
for (let x = startX; x < canvas.width + squareSize; x += squareSize) {
for (let y = startY; y < canvas.height + squareSize; y += squareSize) {
const squareX = x - (gridOffset.current.x % squareSize);
const squareY = y - (gridOffset.current.y % squareSize);
if (
hoveredSquareRef.current &&
Math.floor((x - startX) / squareSize) ===
hoveredSquareRef.current.x &&
Math.floor((y - startY) / squareSize) === hoveredSquareRef.current.y
) {
ctx.fillStyle = hoverFillColor;
ctx.fillRect(squareX, squareY, squareSize, squareSize);
}
ctx.strokeStyle = borderColor;
ctx.strokeRect(squareX, squareY, squareSize, squareSize);
}
}
const gradient = ctx.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
0,
canvas.width / 2,
canvas.height / 2,
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2
);
gradient.addColorStop(0, "rgba(0, 0, 0, 0)");
gradient.addColorStop(1, "#060606");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const updateAnimation = () => {
const effectiveSpeed = Math.max(speed, 0.1);
switch (direction) {
case "right":
gridOffset.current.x =
(gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
break;
case "left":
gridOffset.current.x =
(gridOffset.current.x + effectiveSpeed + squareSize) % squareSize;
break;
case "up":
gridOffset.current.y =
(gridOffset.current.y + effectiveSpeed + squareSize) % squareSize;
break;
case "down":
gridOffset.current.y =
(gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
break;
case "diagonal":
gridOffset.current.x =
(gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
gridOffset.current.y =
(gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
break;
default:
break;
}
drawGrid();
requestRef.current = requestAnimationFrame(updateAnimation);
};
const handleMouseMove = (event) => {
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize;
const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize;
const hoveredSquareX = Math.floor(
(mouseX + gridOffset.current.x - startX) / squareSize
);
const hoveredSquareY = Math.floor(
(mouseY + gridOffset.current.y - startY) / squareSize
);
if (
!hoveredSquareRef.current ||
hoveredSquareRef.current.x !== hoveredSquareX ||
hoveredSquareRef.current.y !== hoveredSquareY
) {
hoveredSquareRef.current = { x: hoveredSquareX, y: hoveredSquareY };
}
};
const handleMouseLeave = () => {
hoveredSquareRef.current = null;
};
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseleave", handleMouseLeave);
requestRef.current = requestAnimationFrame(updateAnimation);
return () => {
window.removeEventListener("resize", resizeCanvas);
if (requestRef.current) cancelAnimationFrame(requestRef.current);
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mouseleave", handleMouseLeave);
};
}, [direction, speed, borderColor, hoverFillColor, squareSize]);
return (
<canvas
ref={canvasRef}
className="w-full h-full border-none block"
></canvas>
);
};
export default Squares;

View File

@ -89,11 +89,7 @@ function DockLabel({ children, className = "", showLabel = true, ...rest }) {
}, [isHovered]);
if (!showLabel) {
return (
<span className={`${className} text-[10px] text-campfire-light ml-2`}>
{children}
</span>
);
return null;
}
return (
@ -157,7 +153,7 @@ export default function Dock({
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`}
className={`${className} flex items-end w-fit gap-2 md:gap-4 rounded-2xl border-neutral-700 border-2 pb-2 px-2 md:px-4`}
style={{ height: panelHeight }}
role="toolbar"
aria-label="Application dock"

View File

@ -0,0 +1,279 @@
/*
Installed from https://reactbits.dev/tailwind/
*/
import { useEffect, useRef } from "react";
class Pixel {
constructor(canvas, context, x, y, color, speed, delay) {
this.width = canvas.width;
this.height = canvas.height;
this.ctx = context;
this.x = x;
this.y = y;
this.color = color;
this.speed = this.getRandomValue(0.1, 0.9) * speed;
this.size = 0;
this.sizeStep = Math.random() * 0.4;
this.minSize = 0.5;
this.maxSizeInteger = 2;
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger);
this.delay = delay;
this.counter = 0;
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
this.isIdle = false;
this.isReverse = false;
this.isShimmer = false;
}
getRandomValue(min, max) {
return Math.random() * (max - min) + min;
}
draw() {
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5;
this.ctx.fillStyle = this.color;
this.ctx.fillRect(
this.x + centerOffset,
this.y + centerOffset,
this.size,
this.size
);
}
appear() {
this.isIdle = false;
if (this.counter <= this.delay) {
this.counter += this.counterStep;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer();
} else {
this.size += this.sizeStep;
}
this.draw();
}
disappear() {
this.isShimmer = false;
this.counter = 0;
if (this.size <= 0) {
this.isIdle = true;
return;
} else {
this.size -= 0.1;
}
this.draw();
}
shimmer() {
if (this.size >= this.maxSize) {
this.isReverse = true;
} else if (this.size <= this.minSize) {
this.isReverse = false;
}
if (this.isReverse) {
this.size -= this.speed;
} else {
this.size += this.speed;
}
}
}
function getEffectiveSpeed(value, reducedMotion) {
const min = 0;
const max = 100;
const throttle = 0.001;
const parsed = parseInt(value, 10);
if (parsed <= min || reducedMotion) {
return min;
} else if (parsed >= max) {
return max * throttle;
} else {
return parsed * throttle;
}
}
/**
* You can change/expand these as you like.
*/
const VARIANTS = {
default: {
activeColor: null,
gap: 5,
speed: 35,
colors: " #ff3c00, #FF3300, #FF6A00, #FF0000",
noFocus: false
},
blue: {
activeColor: "#e0f2fe",
gap: 10,
speed: 25,
colors: "#e0f2fe,#7dd3fc,#0ea5e9",
noFocus: false
},
yellow: {
activeColor: "#fef08a",
gap: 3,
speed: 20,
colors: "#fef08a,#fde047,#eab308",
noFocus: false
},
pink: {
activeColor: "#fecdd3",
gap: 6,
speed: 80,
colors: "#fecdd3,#fda4af,#e11d48",
noFocus: true
}
};
export default function PixelCard({
variant = "default",
gap,
speed,
colors,
noFocus,
className = "",
children
}) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const pixelsRef = useRef([]);
const animationRef = useRef(null);
const timePreviousRef = useRef(performance.now());
const reducedMotion = useRef(
window.matchMedia("(prefers-reduced-motion: reduce)").matches
).current;
const variantCfg = VARIANTS[variant] || VARIANTS.default;
const finalGap = gap ?? variantCfg.gap;
const finalSpeed = speed ?? variantCfg.speed;
const finalColors = colors ?? variantCfg.colors;
const finalNoFocus = noFocus ?? variantCfg.noFocus;
const initPixels = () => {
if (!containerRef.current || !canvasRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
const ctx = canvasRef.current.getContext("2d");
canvasRef.current.width = width;
canvasRef.current.height = height;
canvasRef.current.style.width = `${width}px`;
canvasRef.current.style.height = `${height}px`;
const colorsArray = finalColors.split(",");
const pxs = [];
for (let x = 0; x < width; x += parseInt(finalGap, 10)) {
for (let y = 0; y < height; y += parseInt(finalGap, 10)) {
const color =
colorsArray[Math.floor(Math.random() * colorsArray.length)];
const dx = x - width / 2;
const dy = y - height / 2;
const distance = Math.sqrt(dx * dx + dy * dy);
const delay = reducedMotion ? 0 : distance;
pxs.push(
new Pixel(
canvasRef.current,
ctx,
x,
y,
color,
getEffectiveSpeed(finalSpeed, reducedMotion),
delay
)
);
}
}
pixelsRef.current = pxs;
};
const doAnimate = (fnName) => {
animationRef.current = requestAnimationFrame(() => doAnimate(fnName));
const timeNow = performance.now();
const timePassed = timeNow - timePreviousRef.current;
const timeInterval = 1000 / 60; // ~60 FPS
if (timePassed < timeInterval) return;
timePreviousRef.current = timeNow - (timePassed % timeInterval);
const ctx = canvasRef.current?.getContext("2d");
if (!ctx || !canvasRef.current) return;
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
let allIdle = true;
for (let i = 0; i < pixelsRef.current.length; i++) {
const pixel = pixelsRef.current[i];
pixel[fnName]();
if (!pixel.isIdle) {
allIdle = false;
}
}
if (allIdle) {
cancelAnimationFrame(animationRef.current);
}
};
const handleAnimation = (name) => {
cancelAnimationFrame(animationRef.current);
animationRef.current = requestAnimationFrame(() => doAnimate(name));
};
const onMouseEnter = () => handleAnimation("disappear");
const onMouseLeave = () => handleAnimation("appear");
const onFocus = (e) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
handleAnimation("disappear");
};
const onBlur = (e) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
handleAnimation("appear");
};
useEffect(() => {
initPixels();
const observer = new ResizeObserver(() => {
initPixels();
});
if (containerRef.current) {
observer.observe(containerRef.current);
handleAnimation("appear");
}
return () => {
observer.disconnect();
cancelAnimationFrame(animationRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [finalGap, finalSpeed, finalColors, finalNoFocus]);
return (
<div
ref={containerRef}
className={`h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none ${className}`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={finalNoFocus ? undefined : onFocus}
onBlur={finalNoFocus ? undefined : onBlur}
tabIndex={finalNoFocus ? -1 : 0}
>
<canvas
className="w-full h-full block"
ref={canvasRef}
/>
{children}
</div>
);
}

View File

@ -63,9 +63,8 @@ export default function TiltedCard({
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 imageData = ctx.getImageData(0, 0, img.width, img.height);
const data = imageData.data;
let r = 0, g = 0, b = 0;
@ -87,8 +86,10 @@ export default function TiltedCard({
// Вычисляем яркость по формуле
const brightness = (r * 0.299 + g * 0.587 + b * 0.114);
// Если яркость больше 128, считаем фон светлым
setIsDark(brightness < 128);
// Проверяем, является ли фон светлым
// Используем более простую логику с одним порогом
const isLightBackground = brightness > 180;
setIsDark(!isLightBackground);
} catch (error) {
// В случае ошибки CORS, используем темный фон по умолчанию
console.warn('Не удалось определить яркость изображения:', error);
@ -160,7 +161,7 @@ export default function TiltedCard({
)}
<motion.div
className="relative [transform-style:preserve-3d]"
className="relative [transform-style:preserve-3d] group"
style={{
width: imageWidth,
height: imageHeight,
@ -172,7 +173,7 @@ export default function TiltedCard({
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
className="absolute top-0 left-0 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)] group-hover:z-50"
style={{
width: imageWidth,
height: imageHeight,
@ -191,27 +192,23 @@ export default function TiltedCard({
<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 className="bg-campfire-dark/80 text-campfire-amber px-3 py-1.5 rounded-full shadow-lg font-bold text-sm flex items-center gap-1.5">
<FaFire className="text-campfire-amber text-sm drop-shadow-[0_0_8px_rgba(255,51,0,1)]" />
<span className="drop-shadow-[0_0_8px_rgba(255,51,0,1)]">{rating}</span>
</div>
</motion.div>
)}
{releaseDate && (
<motion.div
className="absolute top-3 left-3 z-[3] will-change-transform [transform:translateZ(40px)]"
>
<div className="bg-campfire-dark/80 text-campfire-light px-3 py-1.5 rounded-full shadow-lg font-bold text-sm">
{formatYear(releaseDate)}
</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 && (

View File

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

View File

@ -0,0 +1,16 @@
// Components
export { default as TiltedCard } from './Components/TiltedCard/TiltedCard';
export { default as PixelCard } from './Components/PixelCard/PixelCard';
export { default as Stepper } from './Components/Stepper/Stepper';
export { default as Dock } from './Components/Dock/Dock';
// Animations
export { default as ClickSpark } from './Animations/ClickSpark/ClickSpark';
// Backgrounds
export { default as GridMotion } from './Backgrounds/GridMotion/GridMotion';
export { default as Squares } from './Backgrounds/Squares/Squares';
export { default as Dither } from './Backgrounds/Dither/Dither';
// Text Animations
export { default as FuzzyText } from './TextAnimations/FuzzyText/FuzzyText';

View File

@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { FaComment, FaReply } from 'react-icons/fa';
import { pb } from '../../services/pocketbaseService';
import { createReviewCommentNotification, createCommentReplyNotification } from '../../services/notificationService';
const CommentSection = ({ reviewId }) => {
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [replyTo, setReplyTo] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadComments();
}, [reviewId]);
const loadComments = async () => {
try {
const records = await pb.collection('review_comments').getList(1, 50, {
filter: `review_id = "${reviewId}"`,
sort: '-created',
expand: 'user_id,parent_id'
});
setComments(records.items);
} catch (error) {
console.error('Error loading comments:', error);
}
};
const handleSubmitComment = async (e) => {
e.preventDefault();
if (!newComment.trim() || isLoading) return;
setIsLoading(true);
try {
const authData = pb.authStore.model;
if (!authData) {
throw new Error('Требуется авторизация');
}
// Получаем рецензию для уведомления
const review = await pb.collection('reviews').getOne(reviewId, {
expand: 'media_id'
});
const commentData = {
review_id: reviewId,
user_id: authData.id,
content: newComment.trim(),
parent_id: replyTo?.id || null
};
const comment = await pb.collection('review_comments').create(commentData);
// Создаем уведомление
if (replyTo) {
// Уведомление о ответе на комментарий
await createCommentReplyNotification(
replyTo.user_id,
replyTo,
comment,
authData
);
} else {
// Уведомление о новом комментарии к рецензии
await createReviewCommentNotification(
review.user_id,
review,
comment,
authData
);
}
setNewComment('');
setReplyTo(null);
await loadComments();
} catch (error) {
console.error('Error submitting comment:', error);
} finally {
setIsLoading(false);
}
};
const renderComment = (comment) => {
const user = comment.expand?.user_id;
const isReply = comment.parent_id;
return (
<div key={comment.id} className={`mt-4 ${isReply ? 'ml-8' : ''}`}>
<div className="flex items-start space-x-2">
<img
src={user?.avatar || '/default-avatar.png'}
alt={user?.username}
className="w-8 h-8 rounded-full"
/>
<div className="flex-1">
<div className="bg-gray-100 rounded-lg p-3">
<div className="font-medium">{user?.username}</div>
<p className="text-gray-700">{comment.content}</p>
</div>
<div className="mt-1 text-sm text-gray-500">
<button
onClick={() => setReplyTo(comment)}
className="hover:text-blue-500"
>
<FaReply className="inline mr-1" />
Ответить
</button>
</div>
</div>
</div>
{comment.expand?.parent_id && (
<div className="mt-2 text-sm text-gray-500">
Ответ на комментарий {comment.expand.parent_id.user_id.username}
</div>
)}
</div>
);
};
return (
<div className="mt-6">
<h3 className="text-lg font-medium mb-4">
<FaComment className="inline mr-2" />
Комментарии
</h3>
<form onSubmit={handleSubmitComment} className="mb-6">
<div className="flex space-x-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder={replyTo ? `Ответить ${replyTo.expand?.user_id.username}` : 'Написать комментарий...'}
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading}
className="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{isLoading ? 'Отправка...' : 'Отправить'}
</button>
</div>
{replyTo && (
<div className="mt-2 text-sm text-gray-500">
Ответ на комментарий {replyTo.expand?.user_id.username}
<button
onClick={() => setReplyTo(null)}
className="ml-2 text-red-500 hover:text-red-600"
>
Отменить
</button>
</div>
)}
</form>
<div className="space-y-4">
{comments.map(renderComment)}
</div>
</div>
);
};
export default CommentSection;

View File

@ -3,6 +3,7 @@ import { FaHeart, FaRegHeart } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { pb } from '../../services/pocketbaseService';
import { toast } from 'react-toastify';
import { createReviewLikeNotification } from '../../services/notificationService';
const LikeButton = ({ reviewId, initialLikes = 0, onLikeChange, reviewOwnerId }) => {
const { user } = useAuth();
@ -18,7 +19,9 @@ const LikeButton = ({ reviewId, initialLikes = 0, onLikeChange, reviewOwnerId })
try {
// Получаем рецензию и проверяем, есть ли пользователь в списке лайков
const review = await pb.collection('reviews').getOne(reviewId);
const review = await pb.collection('reviews').getOne(reviewId, {
expand: 'media_id'
});
const likes = review.likes || [];
setIsLiked(likes.includes(user.id));
setLikesCount(likes.length);
@ -53,7 +56,9 @@ const LikeButton = ({ reviewId, initialLikes = 0, onLikeChange, reviewOwnerId })
setIsLoading(true);
try {
// Получаем текущую рецензию
const review = await pb.collection('reviews').getOne(reviewId);
const review = await pb.collection('reviews').getOne(reviewId, {
expand: 'media_id,user_id'
});
const currentLikes = review.likes || [];
let newLikes;
@ -63,6 +68,15 @@ const LikeButton = ({ reviewId, initialLikes = 0, onLikeChange, reviewOwnerId })
} else {
// Добавляем лайк
newLikes = [...currentLikes, user.id];
// Создаем уведомление о лайке, если это новый лайк и лайкающий не является автором рецензии
if (review.user_id !== user.id) {
await createReviewLikeNotification(
review.user_id,
review,
user
);
}
}
// Обновляем рецензию с новым списком лайков

View File

@ -40,7 +40,7 @@ function ReviewCard({ review, isDetailed = false, characteristics }) {
{/* Review Header */}
<div className="flex justify-between items-start mb-4">
<div className="flex items-center">
<Link to={`/profile/${userProfile?.username}`}> {/* Use userProfile?.id */}
<Link to={`/profile/${userProfile?.login}`}> {/* Use userProfile?.id */}
<img
src={userProfile?.profile_picture || 'https://questhowth.ie/wp-content/uploads/2018/04/user-placeholder.png'} // Use userProfile?.profile_picture
alt={userProfile?.username} // Use userProfile?.username
@ -48,7 +48,7 @@ function ReviewCard({ review, isDetailed = false, characteristics }) {
/>
</Link>
<div>
<Link to={`/profile/${userProfile?.username}`} className="font-medium text-campfire-light hover:text-campfire-amber"> {/* Use userProfile?.id */}
<Link to={`/profile/${userProfile?.login}`} className="font-medium text-campfire-light hover:text-campfire-amber"> {/* Use userProfile?.id */}
{userProfile?.username} {/* Use userProfile?.username */}
</Link>
{userProfile?.is_critic && ( // Use userProfile?.is_critic

View File

@ -123,10 +123,21 @@ function ReviewForm({ mediaId, seasonId, mediaType, progressType, onSubmit, onEd
const handleRatingChange = (category, value) => {
setRatings(prev => ({
...prev,
[category]: value // Store the number directly from FlameRatingInput
}));
const newRatings = {
...ratings,
[category]: value
};
// Рассчитываем overall_rating как среднее всех оценок
const validRatings = Object.values(newRatings).filter(rating => typeof rating === 'number');
const overallRating = validRatings.length > 0
? validRatings.reduce((sum, rating) => sum + rating, 0) / validRatings.length
: 0;
setRatings({
...newRatings,
overall: overallRating
});
};
const handleProgressChange = (value) => {
@ -145,55 +156,35 @@ function ReviewForm({ mediaId, seasonId, mediaType, progressType, onSubmit, onEd
setIsSubmitting(true);
try {
// Validate that all characteristics have a rating (since N/A is removed)
const allRatingsValid = Object.keys(characteristics).every(key =>
typeof ratings[key] === 'number' && ratings[key] >= 1 && ratings[key] <= 10
);
// Validate that progress is filled/selected
const isProgressValid = isProgressSelect ? Object.keys(progressOptions).includes(progress) : progress.trim() !== '';
// Validate that a season is selected if media supports seasons and it's not the overall review
// This validation is now relaxed: if supportsSeasons, formSeasonId can be null OR a valid season ID
const isSeasonSelectedValid = supportsSeasons ? (formSeasonId === null || seasons.some(s => s.id === formSeasonId)) : true;
// Validate that content is not empty (check Quill's internal state or HTML string)
// Quill's empty state is typically '<p><br></p>' or just ''
const isContentValid = content.trim() !== '' && content !== '<p><br></p>';
if (!allRatingsValid || !isProgressValid || !isSeasonSelectedValid || !isContentValid) {
console.error("Validation failed: Not all characteristics have a valid rating, progress is invalid/empty, season selection is invalid, or content is empty.");
// Optionally set an error message here if needed, though button is disabled
setIsSubmitting(false);
return;
}
// Рассчитываем overall_rating перед отправкой
const validRatings = Object.entries(ratings)
.filter(([key, value]) => key !== 'overall' && typeof value === 'number');
const overallRating = validRatings.length > 0
? validRatings.reduce((sum, [_, rating]) => sum + rating, 0) / validRatings.length
: 0;
const reviewData = {
media_id: mediaId,
season_id: formSeasonId, // Use the season selected in the form (can be null)
season_id: formSeasonId,
media_type: mediaType,
content, // Use content from Quill editor state (HTML string)
ratings, // Store the ratings object { [key]: number }
content,
ratings: {
...ratings,
overall: overallRating
},
has_spoilers: hasSpoilers,
progress, // Include progress (text field)
progress: progress,
progress_type: progressType
};
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
setIsEditing(false);
} else {
// If creating a new review
await onSubmit(reviewData);
// Form reset is handled by the useEffect when existingReview becomes null or changes
}
} catch (error) {
console.error('Error submitting review:', error);
// Optionally set an error state to display to the user
} finally {
setIsSubmitting(false);
}

View File

@ -1,9 +1,11 @@
import React, { useState } from 'react';
import React, { useState, memo } 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';
import { PixelCard } from '../reactbits';
import LazyImage from '../common/LazyImage';
// Mapping for watched/completed status values
const watchedStatusLabels = {
@ -18,7 +20,7 @@ const completedStatusLabels = {
// ReviewItem now expects review, media, season, reviewCharacteristics, isProfilePage, and isSmallCard props
function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfilePage = false, isSmallCard = false }) {
const ReviewItem = memo(function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfilePage = false, isSmallCard = false }) {
const [showFullReview, setShowFullReview] = useState(false);
const [showSpoilers, setShowSpoilers] = useState(false);
@ -32,13 +34,15 @@ function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfi
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);
const plainTextContent = sanitizedContent.replace(/<[^>]*>?/gm, '');
const isLongReview = plainTextContent.length > 300;
// Determine progress display based on media's progress_type
@ -85,10 +89,10 @@ function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfi
{/* 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
<LazyImage
src={getFileUrl(reviewMedia, 'poster')}
alt={`Постер ${reviewMedia.title}`}
className="w-full h-full object-cover" // Ensure image covers the container
className="w-full h-full"
/>
</div>
)}
@ -96,10 +100,10 @@ function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfi
{/* User Avatar and Title */}
<div className={`flex items-center mb-3 ${isProfilePage && reviewMedia?.poster ? 'pr-20' : ''}`}> {/* Added right padding conditionally */}
{reviewUser && (
<img
<LazyImage
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"
className="w-8 h-8 rounded-full mr-3 border border-campfire-ash/30"
/>
)}
<h3 className="text-sm font-bold text-campfire-amber leading-tight line-clamp-2 flex-grow">
@ -141,10 +145,10 @@ function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfi
{/* Media Poster - Only show on profile page large cards (Showcase) */}
{isProfilePage && reviewMedia?.poster && (
<Link to={reviewLink} className="block mb-4 self-center">
<img
<LazyImage
src={getFileUrl(reviewMedia, 'poster')}
alt={`Постер ${reviewMedia.title}`}
className="w-32 h-auto object-cover rounded-md"
className="w-32 h-auto rounded-md"
/>
</Link>
)}
@ -154,18 +158,18 @@ function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfi
<div className="flex items-center">
{/* User Avatar */}
{reviewUser && (
<Link to={`/profile/${reviewUser.username}`} className="flex-shrink-0 mr-4">
<img
<Link to={`/profile/${reviewUser.login}`} className="flex-shrink-0 mr-4">
<LazyImage
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"
className="w-10 h-10 rounded-full 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">
<Link to={`/profile/${reviewUser.login}`} className="text-campfire-light font-semibold hover:underline text-sm">
{reviewUser.username}
</Link>
)}
@ -210,37 +214,42 @@ function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfi
{/* 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' : ''}`}>
<div className="text-campfire-ash leading-relaxed mb-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>
<PixelCard
variant="default"
className="w-full group"
noFocus={true}
gap={9}
onMouseEnter={() => setShowSpoilers(true)}
>
<div className="absolute inset-0 flex flex-col items-center justify-center p-6 text-center opacity-100 transition-opacity duration-300 group-hover:opacity-0 pointer-events-none">
<div className="text-2xl font-bold text-campfire-amber mb-4"> Внимание!</div>
<p className="text-campfire-light mb-6">Эта рецензия содержит спойлеры к произведению</p>
<div className="px-6 py-2 bg-campfire-ash/20 text-campfire-amber rounded-xl">
Наведите для просмотра
</div>
</div>
<div className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="p-6 h-full overflow-y-auto custom-scrollbar">
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
</div>
</div>
</PixelCard>
) : (
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
<div className="relative group">
<div className="p-6 overflow-y-auto custom-scrollbar max-h-[400px] border rounded-xl border-campfire-ash/20">
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
</div>
</div>
)}
</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()}
<span>{new Date(review.created).toLocaleDateString()}</span>
{renderProgress()}
</div>
<LikeButton
reviewId={review.id}
@ -251,6 +260,6 @@ function ReviewItem({ review, media, season, reviewCharacteristics = {}, isProfi
</div>
</div>
);
}
});
export default ReviewItem;

View File

@ -4,7 +4,7 @@ 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
Alpha 8.4
</div>
</div>
);

View File

@ -3,7 +3,7 @@ import { FiSearch, FiX } from 'react-icons/fi';
import { searchMedia } from '../../services/pocketbaseService';
import SearchResults from './SearchResults';
const SearchBar = ({ onClose }) => {
const SearchBar = ({ onClose, mobileMode = false }) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
@ -86,6 +86,17 @@ const SearchBar = ({ onClose }) => {
{/* Search Results */}
{(query.length >= 2 || results.length > 0) && (
mobileMode ? (
<div className="mt-2 bg-campfire-charcoal/95 backdrop-blur-md border-b border-campfire-ash/30 shadow-lg rounded-lg">
<div className="container-custom mx-auto px-4 py-4">
{isLoading ? (
<div className="text-campfire-light text-center">Поиск...</div>
) : (
<SearchResults results={results} onResultClick={handleResultClick} />
)}
</div>
</div>
) : (
<div className="fixed left-0 right-0 top-[72px] bg-campfire-charcoal/95 backdrop-blur-md border-b border-campfire-ash/30 shadow-lg">
<div className="container-custom mx-auto px-4 py-4">
{isLoading ? (
@ -95,6 +106,7 @@ const SearchBar = ({ onClose }) => {
)}
</div>
</div>
)
)}
</div>
);

View File

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

View File

@ -1,26 +1,26 @@
import React, { createContext, useContext, useCallback, useRef } from 'react';
import React, { createContext, useContext, useRef, useCallback } 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);
}
}, []);
React.useEffect(() => {
const handleClick = (e) => {
addSpark(e.clientX, e.clientY);
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [addSpark]);
return (
<ClickSparkContext.Provider value={{ addSpark }}>
<ClickSpark
@ -37,4 +37,12 @@ export const ClickSparkProvider = ({ children }) => {
</ClickSpark>
</ClickSparkContext.Provider>
);
};
export const useClickSpark = () => {
const context = useContext(ClickSparkContext);
if (!context) {
throw new Error('useClickSpark должен использоваться внутри ClickSparkProvider');
}
return context;
};

View File

@ -1,72 +1,104 @@
import React, { createContext, useContext, useState } from 'react';
import { createMedia, validateMediaData, formatMediaData } from '../services/pocketbaseService'; // Import from pocketbaseService, including validation/formatting
import React, { createContext, useContext, useState, useCallback } from 'react';
import { createMedia, validateMediaData, formatMediaData, pb } from '../services/pocketbaseService'; // Import from pocketbaseService, including validation/formatting
import { useCache } from '../hooks/useCache';
import { useErrorBoundary } from '../hooks/useErrorBoundary';
const MediaContext = createContext();
export const useMedia = () => {
const context = useContext(MediaContext);
if (!context) {
throw new Error('useMedia must be used within a MediaProvider');
}
return context;
};
const MediaContext = createContext(null);
export const MediaProvider = ({ children }) => {
// Removed searchResults and selectedMedia states as search/details logic is likely handled elsewhere now
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedMedia, setSelectedMedia] = useState(null);
const { error, handleError, clearError } = useErrorBoundary();
// Removed handleSearch and handleSelectMedia as they likely relied on the removed mediaService functionality
// If external search/details is needed, this logic will need to be reimplemented using a different service/API.
const handleCreateMedia = async (mediaData) => {
try {
setLoading(true);
setError(null);
console.log('MediaContext: Попытка создания медиа:', mediaData);
// Валидация данных (теперь из pocketbaseService)
const errors = validateMediaData(mediaData);
if (errors.length > 0) {
console.error('MediaContext: Ошибки валидации:', errors);
throw new Error('Ошибки валидации:\n' + errors.join('\n'));
// Кэширование медиа-контента
const { data: mediaList, refetch: refetchMedia } = useCache(
'media_list',
async () => {
try {
const records = await pb.collection('media').getFullList({
sort: '-created',
expand: 'genres,seasons'
});
return records;
} catch (err) {
handleError(err);
return [];
}
console.log('MediaContext: Валидация данных успешна.');
// Форматирование данных (теперь из pocketbaseService)
const formattedData = formatMediaData(mediaData);
console.log('MediaContext: Данные отформатированы для PocketBase:', formattedData);
// Создание медиа через PocketBase
const newMedia = await createMedia(formattedData);
console.log('MediaContext: Медиа успешно создано:', newMedia);
return newMedia;
} catch (error) {
console.error('MediaContext: Ошибка при создании медиа:', error);
// PocketBase errors might have a response structure
if (error.response && error.response.data) {
console.error('MediaContext: PocketBase Response data:', error.response.data);
// Attempt to extract specific error messages from PocketBase response
const pbErrors = Object.values(error.response.data).map(err => err.message).join('\n');
setError('Ошибка при создании медиа:\n' + pbErrors || error.message || 'Неизвестная ошибка');
} else {
setError(error.message || 'Ошибка при создании медиа');
}
throw error; // Re-throw to allow calling component to handle
} finally {
setLoading(false);
},
{
expiry: 5 * 60 * 1000, // 5 минут
}
};
);
// Получение медиа по ID
const getMediaById = useCallback(async (id) => {
try {
clearError();
const media = await pb.collection('media').getOne(id, {
expand: 'genres,seasons,ratings'
});
return media;
} catch (err) {
handleError(err);
return null;
}
}, [clearError, handleError]);
// Поиск медиа
const searchMedia = useCallback(async (query) => {
try {
clearError();
const records = await pb.collection('media').getFullList({
filter: `title ~ "${query}" || description ~ "${query}"`,
expand: 'genres'
});
return records;
} catch (err) {
handleError(err);
return [];
}
}, [clearError, handleError]);
// Фильтрация медиа
const filterMedia = useCallback(async (filters) => {
try {
clearError();
let filterString = '';
if (filters.genre) {
filterString += `genres ?~ "${filters.genre}"`;
}
if (filters.year) {
filterString += filterString ? ' && ' : '';
filterString += `year = ${filters.year}`;
}
if (filters.type) {
filterString += filterString ? ' && ' : '';
filterString += `type = "${filters.type}"`;
}
const records = await pb.collection('media').getFullList({
filter: filterString,
expand: 'genres'
});
return records;
} catch (err) {
handleError(err);
return [];
}
}, [clearError, handleError]);
const value = {
// Removed searchResults, selectedMedia
loading,
mediaList,
selectedMedia,
error,
// Removed handleSearch, handleSelectMedia
handleCreateMedia
setSelectedMedia,
getMediaById,
searchMedia,
filterMedia,
refetchMedia
};
return (
@ -75,3 +107,11 @@ export const MediaProvider = ({ children }) => {
</MediaContext.Provider>
);
};
export const useMedia = () => {
const context = useContext(MediaContext);
if (!context) {
throw new Error('useMedia должен использоваться внутри MediaProvider');
}
return context;
};

View File

@ -1,20 +1,70 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { pb } from '../services/pocketbaseService';
import { useErrorBoundary } from '../hooks/useErrorBoundary';
const ProfileActionsContext = createContext(null);
export const ProfileActionsProvider = ({ children }) => {
const [shouldOpenEditModal, setShouldOpenEditModal] = useState(false);
const [loading, setLoading] = useState(false);
const { error, handleError, clearError } = useErrorBoundary();
const triggerEditModal = useCallback(() => {
setShouldOpenEditModal(true);
}, []);
// Обновление профиля
const updateProfile = useCallback(async (userId, data) => {
try {
setLoading(true);
clearError();
const updatedProfile = await pb.collection('users').update(userId, data);
return updatedProfile;
} catch (err) {
handleError(err);
throw err;
} finally {
setLoading(false);
}
}, [clearError, handleError]);
const resetEditModalTrigger = useCallback(() => {
setShouldOpenEditModal(false);
}, []);
// Обновление аватара
const updateAvatar = useCallback(async (userId, file) => {
try {
setLoading(true);
clearError();
const formData = new FormData();
formData.append('avatar', file);
const updatedProfile = await pb.collection('users').update(userId, formData);
return updatedProfile;
} catch (err) {
handleError(err);
throw err;
} finally {
setLoading(false);
}
}, [clearError, handleError]);
// Обновление настроек уведомлений
const updateNotificationSettings = useCallback(async (userId, settings) => {
try {
setLoading(true);
clearError();
const updatedSettings = await pb.collection('notification_settings').update(userId, settings);
return updatedSettings;
} catch (err) {
handleError(err);
throw err;
} finally {
setLoading(false);
}
}, [clearError, handleError]);
const value = {
loading,
error,
updateProfile,
updateAvatar,
updateNotificationSettings
};
return (
<ProfileActionsContext.Provider value={{ shouldOpenEditModal, triggerEditModal, resetEditModalTrigger }}>
<ProfileActionsContext.Provider value={value}>
{children}
</ProfileActionsContext.Provider>
);
@ -23,14 +73,7 @@ export const ProfileActionsProvider = ({ children }) => {
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
throw new Error('useProfileActions должен использоваться внутри ProfileActionsProvider');
}
return context;
};

86
src/hooks/useCache.js Normal file
View File

@ -0,0 +1,86 @@
import { useState, useEffect, useCallback } from 'react';
const CACHE_EXPIRY = 5 * 60 * 1000; // 5 минут
const cache = new Map();
export const useCache = (key, fetchFn, options = {}) => {
const {
expiry = CACHE_EXPIRY,
dependencies = [],
skipCache = false
} = options;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Проверяем кэш
if (!skipCache && cache.has(key)) {
const cachedData = cache.get(key);
if (Date.now() - cachedData.timestamp < expiry) {
setData(cachedData.data);
setLoading(false);
return;
}
}
// Если нет в кэше или кэш устарел, делаем запрос
const result = await fetchFn();
// Сохраняем в кэш
if (!skipCache) {
cache.set(key, {
data: result,
timestamp: Date.now()
});
}
setData(result);
} catch (err) {
setError(err);
console.error('Cache Error:', err);
} finally {
setLoading(false);
}
}, [key, fetchFn, expiry, skipCache, ...dependencies]);
// Очистка устаревших данных из кэша
useEffect(() => {
const cleanup = () => {
const now = Date.now();
for (const [cacheKey, value] of cache.entries()) {
if (now - value.timestamp > expiry) {
cache.delete(cacheKey);
}
}
};
const interval = setInterval(cleanup, expiry);
return () => clearInterval(interval);
}, [expiry]);
// Очистка конкретного ключа из кэша
const clearCache = useCallback((cacheKey = key) => {
cache.delete(cacheKey);
}, [key]);
// Очистка всего кэша
const clearAllCache = useCallback(() => {
cache.clear();
}, []);
return {
data,
loading,
error,
refetch: fetchData,
clearCache,
clearAllCache
};
};

View File

@ -0,0 +1,53 @@
import { useState, useCallback } from 'react';
export const useErrorBoundary = () => {
const [error, setError] = useState(null);
const handleError = useCallback((error) => {
console.error('Context Error:', error);
setError(error);
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
return {
error,
handleError,
clearError,
hasError: error !== null
};
};
export const withErrorBoundary = (WrappedComponent) => {
return function WithErrorBoundary(props) {
const { error, handleError, clearError, hasError } = useErrorBoundary();
if (hasError) {
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">
<h2 className="text-xl font-bold mb-2">Произошла ошибка</h2>
<p>{error?.message || 'Неизвестная ошибка'}</p>
<button
onClick={clearError}
className="mt-4 px-4 py-2 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/80 transition-colors"
>
Попробовать снова
</button>
</div>
</div>
);
}
return (
<WrappedComponent
{...props}
error={error}
handleError={handleError}
clearError={clearError}
/>
);
};
};

View File

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

8
src/mobile/App.jsx Normal file
View File

@ -0,0 +1,8 @@
import React from 'react';
import MobileRoutes from './routes';
const MobileApp = () => {
return <MobileRoutes />;
};
export default MobileApp;

View File

@ -0,0 +1,75 @@
import React from 'react';
import Modal from '../../components/common/Modal';
import { FaTrophy, FaFire } from 'react-icons/fa';
import * as FaIcons from 'react-icons/fa';
import * as MdIcons from 'react-icons/md';
import * as IoIcons from 'react-icons/io5';
import * as BiIcons from 'react-icons/bi';
import * as HiIcons from 'react-icons/hi';
import * as RiIcons from 'react-icons/ri';
const AchievementsModal = ({ isOpen, onClose, userAchievements }) => {
const getIconComponent = (iconName) => {
if (!iconName) return FaTrophy;
const prefix = iconName.substring(0, 2).toLowerCase();
switch (prefix) {
case 'fa': return FaIcons[iconName] || FaTrophy;
case 'md': return MdIcons[iconName] || FaTrophy;
case 'io': return IoIcons[iconName] || FaTrophy;
case 'bi': return BiIcons[iconName] || FaTrophy;
case 'hi': return HiIcons[iconName] || FaTrophy;
case 'ri': return RiIcons[iconName] || FaTrophy;
default: return FaTrophy;
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Достижения"
>
<div className="space-y-4">
{userAchievements.length > 0 ? (
userAchievements.map((achievement) => {
const IconComponent = getIconComponent(achievement.icon);
return (
<div
key={achievement.id}
className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-campfire-amber/10 flex items-center justify-center">
<IconComponent className="w-6 h-6 text-campfire-amber" />
</div>
<div className="flex-1">
<h3 className="text-campfire-light font-medium">
{achievement.title}
</h3>
<p className="text-campfire-ash text-sm mt-1">
{achievement.description}
</p>
{achievement.xp_reward > 0 && (
<div className="flex items-center text-campfire-amber text-sm mt-2">
<FaFire className="mr-1" />
<span>+{achievement.xp_reward} XP</span>
</div>
)}
</div>
</div>
</div>
);
})
) : (
<div className="text-center py-8 text-campfire-ash">
У вас пока нет достижений
</div>
)}
</div>
</Modal>
);
};
export default AchievementsModal;

View File

@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
const GridMotionMobile = ({ items = [], gradientColor = 'rgba(251,191,36,0.08)' }) => {
const gridRef = useRef(null);
const rowRefs = useRef([]);
const touchXRef = useRef(window.innerWidth / 2);
const scrollYRef = useRef(window.scrollY);
// Вместо 4 рядов x 12 колонок:
const rows = 3; // например, 10 рядов
const cols = 3; // например, 5 колонок
const totalItems = rows * cols;
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 handleTouchMove = (e) => {
if (e.touches && e.touches.length > 0) {
touchXRef.current = e.touches[0].clientX;
}
};
const handleMouseMove = (e) => {
touchXRef.current = e.clientX;
};
const handleScroll = () => {
scrollYRef.current = window.scrollY;
};
const updateMotion = () => {
// Смещение по X зависит от прокрутки и от касания
const maxMoveAmount = 80;
const baseDuration = 0.2;
const inertiaFactors = [0.5, 0.3, 0.2, 0.1];
// scrollFactor: 0 (верх) 1 (глубокий скролл)
const scrollFactor = Math.min(1, scrollYRef.current / 400);
rowRefs.current.forEach((row, index) => {
if (row) {
const direction = index % 2 === 0 ? 1 : -1;
// mouse/touch + scrollY
const moveAmount = (
((touchXRef.current / window.innerWidth) * maxMoveAmount - maxMoveAmount / 2) * direction +
direction * scrollFactor * maxMoveAmount * 1.2
);
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);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('scroll', handleScroll);
removeAnimationLoop();
};
}, []);
return (
<div ref={gridRef} className="h-full w-full overflow-hidden pointer-events-none select-none">
<section
className="w-full h-[800px] overflow-hidden relative flex items-center justify-center pt-16"
>
<div className="absolute inset-0 z-[1] bg-[url('../../../assets/noise.png')] bg-[length:180px] opacity-10"></div>
<div className={`gap-2 flex-none relative w-[120vw] h-[800px] grid grid-rows-${rows} grid-cols-1 rotate-[-6deg] origin-center z-[2]`}>
{[...Array(rows)].map((_, rowIndex) => (
<div
key={rowIndex}
className={`grid gap-2 grid-cols-${cols}`}
style={{ willChange: 'transform, filter' }}
ref={(el) => (rowRefs.current[rowIndex] = el)}
>
{[...Array(cols)].map((_, itemIndex) => {
const content = combinedItems[rowIndex * cols + itemIndex];
return (
<div key={itemIndex} className="relative">
<div
className="relative w-full h-full overflow-hidden rounded-[8px] bg-[#181818] opacity-70 flex items-center justify-center text-white text-[1rem] shadow-sm"
>
{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-2 text-center z-[1] opacity-60">{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 GridMotionMobile;

View File

@ -0,0 +1,58 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import Dock from '../../components/reactbits/Components/Dock/Dock';
import { FaHome, FaList, FaChartBar, FaUser } from 'react-icons/fa';
import MobileLogo from './MobileLogo';
import { useAuth } from '../../contexts/AuthContext';
const MobileDock = () => {
const navigate = useNavigate();
const location = useLocation();
const { userProfile } = useAuth();
// Формируем navItems динамически, чтобы профиль был с login
const navItems = [
{
icon: <FaHome className="w-6 h-6" />,
path: '/',
},
{
icon: <FaList className="w-6 h-6" />,
path: '/catalog',
},
{
icon: <FaChartBar className="w-6 h-6" />,
path: '/rating',
},
{
icon: <FaUser className="w-6 h-6" />,
path: userProfile ? `/profile/${userProfile.login}` : '/auth/login',
},
];
const items = navItems.map((item) => ({
...item,
onClick: () => navigate(item.path),
className:
location.pathname === item.path || (item.path.startsWith('/profile') && location.pathname.startsWith('/profile'))
? 'border-campfire-amber text-campfire-amber bg-campfire-charcoal/80'
: 'border-campfire-ash/30 text-campfire-ash bg-campfire-charcoal/40',
isRectangular: false,
}));
return (
<div className="w-full max-w-md mx-auto px-2 flex items-center gap-2 justify-center">
<Dock
items={items}
className="w-full justify-between bg-campfire-charcoal/40"
panelHeight={60}
dockHeight={90}
baseItemSize={44}
magnification={60}
distance={120}
/>
</div>
);
};
export default MobileDock;

View File

@ -0,0 +1,15 @@
import React from 'react';
const MobileLogo = ({ small = false }) => (
<div className={`transition-all duration-300 select-none flex items-center justify-center ${small ? 'h-8' : 'h-16'}`} style={{ minHeight: small ? 32 : 64 }}>
<img
src="/logo.png"
alt="Campfire Logo"
className={small ? 'h-8 w-auto' : 'h-16 w-auto'}
style={{ objectFit: 'contain', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.15))' }}
draggable={false}
/>
</div>
);
export default MobileLogo;

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { getFileUrl } from '../../services/pocketbaseService';
import { FaStar } from 'react-icons/fa';
import { motion } from 'framer-motion';
const MobileMediaCard = ({ media }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -5 }}
className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg overflow-hidden"
>
<Link to={`/media/${media.path}`}>
<div className="relative aspect-[2/3]">
<img
src={getFileUrl(media, 'poster')}
alt={media.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark via-transparent to-transparent"></div>
<div className="absolute bottom-0 left-0 right-0 p-3">
<h3 className="text-campfire-light font-semibold line-clamp-1">
{media.title}
</h3>
<div className="flex items-center text-campfire-amber mt-1">
<FaStar className="mr-1" />
<span>{media.average_rating?.toFixed(1) || '0.0'}</span>
</div>
</div>
</div>
</Link>
</motion.div>
);
};
export default MobileMediaCard;

View File

@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { pb, getFileUrl } from '../../services/pocketbaseService';
import { FaUpload, FaTrash } from 'react-icons/fa';
import { motion } from 'framer-motion';
const MobileMediaForm = () => {
const navigate = useNavigate();
const { path } = useParams();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
type: 'movie',
poster: null,
characteristics: [
{ name: 'Сюжет', value: 0 },
{ name: 'Актерская игра', value: 0 },
{ name: 'Визуальные эффекты', value: 0 },
{ name: 'Звук', value: 0 },
{ name: 'Музыка', value: 0 }
],
seasons: []
});
useEffect(() => {
if (path) {
const fetchMedia = async () => {
try {
const media = await pb.collection('media').getFirstListItem(`path="${path}"`);
setFormData({
...media,
poster: null
});
} catch (err) {
console.error('Error fetching media:', err);
setError('Не удалось загрузить данные');
}
};
fetchMedia();
}
}, [path]);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleCharacteristicChange = (index, value) => {
const newCharacteristics = [...formData.characteristics];
newCharacteristics[index].value = parseInt(value);
setFormData(prev => ({
...prev,
characteristics: newCharacteristics
}));
};
const handlePosterChange = async (e) => {
const file = e.target.files[0];
if (file) {
setFormData(prev => ({
...prev,
poster: file
}));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const data = {
...formData,
path: formData.path || formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')
};
if (path) {
// Обновление существующего медиа
await pb.collection('media').update(path, data);
} else {
// Создание нового медиа
await pb.collection('media').create(data);
}
navigate(`/media/${data.path}`);
} catch (err) {
console.error('Error saving media:', err);
setError('Не удалось сохранить данные');
} finally {
setLoading(false);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="min-h-screen bg-campfire-dark p-4"
>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-campfire-light mb-2">Название</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleInputChange}
className="w-full bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg p-3 text-campfire-light"
required
/>
</div>
<div>
<label className="block text-campfire-light mb-2">Описание</label>
<textarea
name="description"
value={formData.description}
onChange={handleInputChange}
className="w-full bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg p-3 text-campfire-light h-32"
required
/>
</div>
<div>
<label className="block text-campfire-light mb-2">Тип</label>
<select
name="type"
value={formData.type}
onChange={handleInputChange}
className="w-full bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg p-3 text-campfire-light"
>
<option value="movie">Фильм</option>
<option value="series">Сериал</option>
<option value="game">Игра</option>
<option value="anime">Аниме</option>
</select>
</div>
<div>
<label className="block text-campfire-light mb-2">Постер</label>
<div className="flex items-center gap-4">
{formData.poster && (
<div className="relative w-24 h-36">
<img
src={typeof formData.poster === 'string' ? getFileUrl(formData, 'poster') : URL.createObjectURL(formData.poster)}
alt="Poster preview"
className="w-full h-full object-cover rounded-lg"
/>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, poster: null }))}
className="absolute -top-2 -right-2 w-6 h-6 bg-campfire-charcoal/80 rounded-full flex items-center justify-center text-campfire-light hover:text-campfire-amber"
>
<FaTrash size={12} />
</button>
</div>
)}
<label className="flex-1">
<input
type="file"
accept="image/*"
onChange={handlePosterChange}
className="hidden"
/>
<div className="flex items-center justify-center gap-2 px-4 py-3 bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg text-campfire-light hover:bg-campfire-charcoal/30 cursor-pointer">
<FaUpload />
<span>Загрузить постер</span>
</div>
</label>
</div>
</div>
<div>
<label className="block text-campfire-light mb-2">Характеристики</label>
<div className="space-y-4">
{formData.characteristics.map((char, index) => (
<div key={char.name}>
<div className="flex justify-between items-center mb-2">
<span className="text-campfire-light">{char.name}</span>
<span className="text-campfire-amber">{char.value}/10</span>
</div>
<input
type="range"
min="0"
max="10"
value={char.value}
onChange={(e) => handleCharacteristicChange(index, e.target.value)}
className="w-full"
/>
</div>
))}
</div>
</div>
{error && (
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
)}
<div className="flex gap-4">
<button
type="button"
onClick={() => navigate(-1)}
className="flex-1 px-4 py-3 bg-campfire-charcoal/50 text-campfire-light border border-campfire-ash/30 rounded-lg hover:bg-campfire-charcoal/70 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-3 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/80 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : path ? 'Сохранить' : 'Создать'}
</button>
</div>
</form>
</motion.div>
);
};
export default MobileMediaForm;

View File

@ -0,0 +1,351 @@
import React, { useState, useEffect } from 'react';
import { createReview, updateReview } from '../../services/pocketbaseService';
import { FaFire } from 'react-icons/fa';
import RatingChart from '../../components/reviews/RatingChart';
import FlameRatingInput from '../../components/reviews/FlameRatingInput';
const watchedStatusLabels = {
not_watched: 'Не просмотрено',
watched: 'Просмотрено',
};
const completedStatusLabels = {
not_completed: 'Не пройдено',
completed: 'Пройдено',
};
function MobileReviewForm({
mediaId,
seasonId,
mediaType,
progress_type,
onSubmit,
onEdit,
onDelete,
characteristics = {},
existingReview,
seasons = [],
selectedSeasonId
}) {
const initialRatings = Object.keys(characteristics).reduce((acc, key) => {
acc[key] = 5;
return acc;
}, {});
const [ratings, setRatings] = useState(initialRatings);
const [content, setContent] = useState('');
const [hasSpoilers, setHasSpoilers] = useState(false);
const [progress, setProgress] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [formSeasonId, setFormSeasonId] = useState(selectedSeasonId);
const getProgressOptions = () => {
if (progress_type === 'hours') {
return null;
} else if (progress_type === 'watched') {
return watchedStatusLabels;
} else if (progress_type === 'completed') {
return completedStatusLabels;
}
return watchedStatusLabels;
};
const progressOptions = getProgressOptions();
const isProgressSelect = progressOptions !== null;
const supportsSeasons = mediaType === 'tv' || mediaType === 'anime';
useEffect(() => {
if (existingReview) {
const populatedRatings = Object.keys(characteristics).reduce((acc, key) => {
acc[key] = typeof existingReview.ratings?.[key] === 'number' ? existingReview.ratings[key] : 5;
return acc;
}, {});
setRatings(populatedRatings);
setContent(existingReview.content || '');
setHasSpoilers(existingReview.has_spoilers ?? false);
setProgress(existingReview.progress || '');
setFormSeasonId(existingReview.season_id || null);
setIsEditing(false);
} else {
const newInitialRatings = Object.keys(characteristics).reduce((acc, key) => {
acc[key] = 5;
return acc;
}, {});
setRatings(newInitialRatings);
setContent('');
setHasSpoilers(false);
if (isProgressSelect) {
setProgress(Object.keys(progressOptions)[0] || '');
} else {
setProgress('');
}
setFormSeasonId(selectedSeasonId);
setIsEditing(false);
}
}, [characteristics, existingReview, progress_type, isProgressSelect, selectedSeasonId, seasons]);
const handleRatingChange = (category, value) => {
const newRatings = {
...ratings,
[category]: value
};
const validRatings = Object.values(newRatings).filter(rating => typeof rating === 'number');
const overallRating = validRatings.length > 0
? validRatings.reduce((sum, rating) => sum + rating, 0) / validRatings.length
: 0;
setRatings({
...newRatings,
overall: overallRating
});
};
const handleProgressChange = (value) => {
setProgress(value);
};
const handleFormSeasonChange = (e) => {
const value = e.target.value === '' ? null : e.target.value;
setFormSeasonId(value);
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
const validRatings = Object.entries(ratings)
.filter(([key, value]) => key !== 'overall' && typeof value === 'number');
const overallRating = validRatings.length > 0
? validRatings.reduce((sum, [_, rating]) => sum + rating, 0) / validRatings.length
: 0;
const reviewData = {
media_id: mediaId,
season_id: formSeasonId,
media_type: mediaType,
content,
ratings: {
...ratings,
overall: overallRating
},
overall_rating: overallRating,
has_spoilers: hasSpoilers,
progress: progress,
progress_type: progress_type
};
if (existingReview && isEditing) {
await onEdit(existingReview.id, reviewData);
setIsEditing(false);
} else {
await onSubmit(reviewData);
}
} catch (error) {
console.error('Error submitting review:', error);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (window.confirm('Вы уверены, что хотите удалить эту рецензию?')) {
setIsSubmitting(true);
try {
await onDelete(existingReview.id, mediaId);
} catch (error) {
console.error('Error deleting review:', error);
} finally {
setIsSubmitting(false);
}
}
};
const isFormValid = content.trim() !== '' &&
Object.keys(characteristics).length > 0 &&
Object.keys(characteristics).every(key =>
typeof ratings[key] === 'number' && ratings[key] >= 1 && ratings[key] <= 10
) &&
(isProgressSelect ? Object.keys(progressOptions).includes(progress) : progress.trim() !== '') &&
(supportsSeasons ? (formSeasonId === null || seasons.some(s => s.id === formSeasonId)) : true);
if (existingReview && !isEditing) {
return (
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-xl p-4 text-center">
<p className="text-campfire-light mb-4">Вы уже написали рецензию на это произведение.</p>
<div className="flex justify-center space-x-4">
<button
onClick={() => setIsEditing(true)}
className="btn-secondary flex items-center"
disabled={isSubmitting}
>
Редактировать
</button>
<button
onClick={handleDelete}
className="btn-danger flex items-center text-white"
disabled={isSubmitting}
>
Удалить
</button>
</div>
</div>
);
}
return (
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-xl p-4">
<form onSubmit={handleSubmit}>
<div className="space-y-6">
{supportsSeasons && seasons.length > 0 && (
<div>
<label htmlFor="season-select" className="block mb-2 text-campfire-light">
Сезон <span className="text-red-500">*</span>
</label>
<select
id="season-select"
value={formSeasonId === null ? '' : formSeasonId}
onChange={handleFormSeasonChange}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-xl text-campfire-light"
required={supportsSeasons && formSeasonId === undefined}
>
<option value="">Общее</option>
{seasons.map(season => (
<option key={season.id} value={season.id}>
Сезон {season.season_number} {season.title ? ` - ${season.title}` : ''}
</option>
))}
</select>
</div>
)}
<div>
<label className="block mb-2 text-campfire-light">
{progress_type === 'hours'
? 'Часов проведено'
: progress_type === 'watched'
? 'Статус просмотра'
: progress_type === 'completed'
? 'Статус прохождения'
: 'Прогресс'
} <span className="text-red-500">*</span>
</label>
{isProgressSelect ? (
<div className="flex rounded-xl overflow-hidden border border-campfire-ash/30">
{Object.entries(progressOptions).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => handleProgressChange(key)}
className={`flex-1 text-center py-3 text-sm font-medium ${
progress === key
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-dark text-campfire-ash'
}`}
>
{label}
</button>
))}
</div>
) : (
<input
type="number"
min="0"
step="0.5"
placeholder="Введите количество часов..."
value={progress}
onChange={(e) => handleProgressChange(e.target.value)}
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-xl text-campfire-light"
required
/>
)}
</div>
<div className="space-y-4">
{Object.entries(characteristics).map(([key, label]) => (
<div key={key} className="space-y-2">
<div className="flex justify-between items-center mb-1">
<label className="block text-sm font-medium text-campfire-light">{label}</label>
<span className="flex items-center text-campfire-amber font-bold text-lg">
<FaFire className="mr-1 text-base" />
{ratings[key] !== undefined ? ratings[key] : 5}
</span>
</div>
<FlameRatingInput
value={ratings[key] !== undefined ? ratings[key] : 5}
onChange={(value) => handleRatingChange(key, value)}
/>
</div>
))}
</div>
<div>
<label className="block mb-2 text-campfire-light text-sm font-medium">
Ваша рецензия <span className="text-red-500">*</span>
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Поделитесь своими мыслями об этом произведении..."
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-xl text-campfire-light min-h-[200px] resize-y"
required
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="spoiler-check"
checked={hasSpoilers}
onChange={(e) => setHasSpoilers(e.target.checked)}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2"
/>
<label htmlFor="spoiler-check" className="ml-2 text-campfire-light text-sm">
Эта рецензия содержит спойлеры
</label>
</div>
<div>
<p className="text-center text-campfire-light mb-4 font-semibold">Предварительный просмотр вашей оценки</p>
<RatingChart
ratings={Object.entries(ratings).reduce((acc, [key, value]) => {
if (characteristics.hasOwnProperty(key) && typeof value === 'number' && value >= 1 && value <= 10) {
acc[key] = value;
}
return acc;
}, {})}
labels={characteristics}
size="medium"
/>
</div>
</div>
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={isSubmitting || !isFormValid}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (existingReview ? 'Сохранение...' : 'Отправка...') : (existingReview ? 'Сохранить изменения' : 'Отправить рецензию')}
</button>
</div>
{!isFormValid && (
<p className="text-status-error text-sm mt-2 text-right">
Пожалуйста, заполните все обязательные поля (рецензия, прогресс, все оценки от 1 до 10{supportsSeasons ? ', сезон' : ''}).
</p>
)}
{Object.keys(characteristics).length === 0 && (
<p className="text-status-error text-sm mt-2 text-right">
Характеристики для этого произведения не загружены.
</p>
)}
</form>
</div>
);
}
export default MobileReviewForm;

View File

@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { FaCheckCircle, FaRegCircle, FaFire } from 'react-icons/fa';
import Modal from '../../components/common/Modal';
function ShowcaseSortModal({ isOpen, onClose, reviews = [], showcase = [], onSave }) {
const [selectedReviewIds, setSelectedReviewIds] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
// Инициализируем состояние только при первом открытии модального окна
useEffect(() => {
if (isOpen && selectedReviewIds.length === 0) {
setSelectedReviewIds(showcase || []);
}
}, [isOpen]);
// Сбрасываем состояние при закрытии
useEffect(() => {
if (!isOpen) {
setSelectedReviewIds([]);
setError(null);
}
}, [isOpen]);
const handleToggleReview = (reviewId) => {
setSelectedReviewIds(prev => {
const isSelected = prev.includes(reviewId);
if (isSelected) {
return prev.filter(id => id !== reviewId);
} else {
if (prev.length < 3) {
return [...prev, reviewId];
} else {
alert('Вы можете выбрать до 3 рецензий для витрины.');
return prev;
}
}
});
};
const handleSave = async () => {
if (typeof onSave !== 'function') {
console.error('onSave is not a function');
setError('Ошибка: функция сохранения не определена');
return;
}
setIsSubmitting(true);
setError(null);
try {
await onSave(selectedReviewIds);
onClose();
} catch (err) {
console.error('Error updating showcase:', err);
setError(err.message || 'Произошла ошибка при обновлении витрины.');
} finally {
setIsSubmitting(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Управление витриной (${selectedReviewIds.length}/3)`}
size="lg"
>
<div className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
Ошибка: {error}
</div>
)}
<p className="text-campfire-ash text-sm">Выберите до 3 рецензий, которые будут отображаться на вашей витрине.</p>
{reviews.length > 0 ? (
<ul className="space-y-3 max-h-96 overflow-y-auto pr-2 custom-scrollbar">
{reviews.map(review => {
const reviewMedia = review.expand?.media_id;
const reviewSeason = review.expand?.season_id;
const isSelected = selectedReviewIds.includes(review.id);
const reviewTitle = reviewMedia
? `${reviewMedia.title}${reviewSeason ? ` - Сезон ${reviewSeason.season_number}${reviewSeason.title ? `: ${reviewSeason.title}` : ''}` : ''}`
: 'Загрузка...';
return (
<li
key={review.id}
className={`p-3 rounded-md border cursor-pointer flex items-center justify-between transition-colors duration-200
${isSelected
? 'bg-campfire-amber/20 border-campfire-amber text-campfire-light'
: 'bg-campfire-dark border-campfire-ash/30 text-campfire-ash hover:bg-campfire-ash/10'
}`}
onClick={() => handleToggleReview(review.id)}
>
<div className="flex-grow mr-4">
<p className="font-semibold text-sm">{reviewTitle}</p>
<p className="text-xs mt-1 flex items-center">
Оценка: <span className="flex items-center text-campfire-amber ml-1 font-bold">
<FaFire className="mr-1 text-xs" />
{review.overall_rating !== null && review.overall_rating !== undefined ? review.overall_rating.toFixed(1) : 'N/A'} / 10
</span>
</p>
</div>
{isSelected ? (
<FaCheckCircle size={20} className="text-campfire-amber" />
) : (
<FaRegCircle size={20} className="text-campfire-ash" />
)}
</li>
);
})}
</ul>
) : (
<div className="text-center py-4 text-campfire-ash">
У вас пока нет рецензий для добавления на витрину.
</div>
)}
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={onClose}
className="btn-secondary"
disabled={isSubmitting}
>
Отмена
</button>
<button
type="button"
onClick={handleSave}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? 'Сохранение...' : 'Сохранить витрину'}
</button>
</div>
</div>
</Modal>
);
}
export default ShowcaseSortModal;

View File

@ -0,0 +1,71 @@
import React from 'react';
import Modal from '../../components/common/Modal';
import { FaPalette, FaSquare, FaWaveSquare } from 'react-icons/fa';
const ShowcaseThemeModal = ({ isOpen, onClose, currentTheme, onSelectTheme }) => {
const themes = [
{
id: 'none',
name: 'Без темы',
icon: FaPalette,
description: 'Простой фон без анимации'
},
{
id: 'squares',
name: 'Квадраты',
icon: FaSquare,
description: 'Анимированные квадраты'
},
{
id: 'dither',
name: 'Волны',
icon: FaWaveSquare,
description: 'Анимированные волны'
}
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Выберите тему витрины"
>
<div className="space-y-4">
{themes.map((theme) => {
const Icon = theme.icon;
const isSelected = currentTheme === theme.id;
return (
<button
key={theme.id}
onClick={() => onSelectTheme(theme.id)}
className={`w-full p-4 rounded-lg border transition-all duration-200 ${
isSelected
? 'bg-campfire-amber/20 border-campfire-amber text-campfire-light'
: 'bg-campfire-charcoal/20 border-campfire-ash/30 text-campfire-ash hover:bg-campfire-charcoal/30'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
isSelected ? 'bg-campfire-amber/20' : 'bg-campfire-charcoal/40'
}`}>
<Icon className={`w-6 h-6 ${isSelected ? 'text-campfire-amber' : 'text-campfire-ash'}`} />
</div>
<div className="flex-1 text-left">
<h3 className={`font-medium ${isSelected ? 'text-campfire-light' : 'text-campfire-ash'}`}>
{theme.name}
</h3>
<p className="text-sm text-campfire-ash mt-1">
{theme.description}
</p>
</div>
</div>
</button>
);
})}
</div>
</Modal>
);
};
export default ShowcaseThemeModal;

View File

@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import MobileDock from '../components/MobileDock';
import MobileLogo from '../components/MobileLogo';
const HEADER_HEIGHT = 86; // px
const DOCK_SCROLL_THRESHOLD = 18;
const MobileLayout = () => {
const [showDock, setShowDock] = useState(false);
const location = useLocation();
useEffect(() => {
const handleScroll = () => {
setShowDock(window.scrollY > DOCK_SCROLL_THRESHOLD);
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [location.pathname]);
return (
<div className="min-h-screen bg-campfire-dark flex flex-col">
{/* Fixed контейнер для логотипа и Dock */}
<div
className="fixed top-0 left-0 w-full z-50 flex items-center justify-center"
style={{ height: `${HEADER_HEIGHT}px`, minHeight: `${HEADER_HEIGHT}px` }}
>
{/* Логотип */}
<div
className={`absolute left-0 top-0 w-full h-full flex items-center justify-center transition-transform duration-500 ${showDock ? '-translate-y-full' : 'translate-y-0'}`}
style={{ willChange: 'transform' }}
>
<Link to="/" aria-label="На главную">
<MobileLogo />
</Link>
</div>
{/* Dock */}
<div
className={`absolute left-0 top-0 w-full h-full flex items-center justify-center transition-transform duration-500 ${showDock ? 'translate-y-0' : 'translate-y-full'}`}
style={{ willChange: 'transform' }}
>
<MobileDock />
</div>
</div>
<main className="flex-grow pt-[165px]">
<Outlet />
</main>
</div>
);
};
export default MobileLayout;

View File

@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { FaEnvelope, FaLock, FaUser, FaArrowLeft } from 'react-icons/fa';
const MobileAuth = () => {
const navigate = useNavigate();
const location = useLocation();
const { login, register } = useAuth();
const [isLogin, setIsLogin] = useState(location.pathname === '/auth/login');
const [formData, setFormData] = useState({
email: '',
password: '',
username: ''
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
if (isLogin) {
await login(formData.email, formData.password);
} else {
await register(formData.email, formData.password, formData.username);
}
navigate('/');
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<div className="min-h-screen flex flex-col px-4 py-4">
<button
onClick={() => navigate(-1)}
className="flex items-center text-campfire-light mb-6"
>
<FaArrowLeft className="mr-2" />
Назад
</button>
<div className="flex-1 flex flex-col justify-center">
<h1 className="text-2xl font-bold text-campfire-light mb-6 text-center">
{isLogin ? 'Вход' : 'Регистрация'}
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div className="relative">
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Имя пользователя"
required
className="w-full pl-10 pr-4 py-2 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg text-campfire-light focus:outline-none focus:border-campfire-amber"
/>
<FaUser className="absolute left-3 top-1/2 transform -translate-y-1/2 text-campfire-ash" />
</div>
)}
<div className="relative">
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
required
className="w-full pl-10 pr-4 py-2 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg text-campfire-light focus:outline-none focus:border-campfire-amber"
/>
<FaEnvelope className="absolute left-3 top-1/2 transform -translate-y-1/2 text-campfire-ash" />
</div>
<div className="relative">
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Пароль"
required
className="w-full pl-10 pr-4 py-2 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg text-campfire-light focus:outline-none focus:border-campfire-amber"
/>
<FaLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-campfire-ash" />
</div>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-campfire-amber text-campfire-dark rounded-lg font-semibold hover:bg-campfire-amber/90 transition-colors disabled:opacity-50"
>
{loading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-campfire-dark"></div>
</div>
) : isLogin ? (
'Войти'
) : (
'Зарегистрироваться'
)}
</button>
</form>
<div className="mt-6 text-center">
<button
onClick={() => setIsLogin(!isLogin)}
className="text-campfire-amber hover:text-campfire-amber/90"
>
{isLogin
? 'Нет аккаунта? Зарегистрируйтесь'
: 'Уже есть аккаунт? Войдите'}
</button>
</div>
</div>
</div>
);
};
export default MobileAuth;

View File

@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { pb } from '../../services/pocketbaseService';
import { getFileUrl } from '../../services/pocketbaseService';
import { FaStar, FaFilter } from 'react-icons/fa';
import SearchBar from '../../components/ui/SearchBar';
const MobileCatalog = () => {
const [media, setMedia] = useState([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
type: '',
genre: '',
year: '',
sort: '-created'
});
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
fetchMedia();
}, [filters]);
const fetchMedia = async () => {
try {
setLoading(true);
const filter = [];
if (filters.type) {
filter.push(`type = "${filters.type}"`);
}
if (filters.genre) {
filter.push(`genres ?~ "${filters.genre}"`);
}
if (filters.year) {
filter.push(`year = ${filters.year}`);
}
const records = await pb.collection('media').getList(1, 50, {
sort: filters.sort,
filter: filter.join(' && ')
});
setMedia(records.items);
} catch (error) {
console.error('Error fetching media:', error);
} finally {
setLoading(false);
}
};
const handleFilterChange = (key, value) => {
setFilters(prev => ({
...prev,
[key]: value
}));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
return (
<div className="min-h-screen bg-campfire-dark">
<div className="px-4 pt-4">
<SearchBar mobileMode={true} />
</div>
{/* Фильтры */}
<div className="px-4 py-4">
<div className="mb-6">
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full flex items-center justify-center space-x-2 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg p-3 text-campfire-light"
>
<FaFilter />
<span>Фильтры</span>
</button>
{showFilters && (
<div className="mt-4 space-y-4 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg p-4">
<div>
<label className="block text-campfire-light mb-2">Тип</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="w-full bg-campfire-dark border border-campfire-ash/30 rounded-lg p-2 text-campfire-light"
>
<option value="">Все</option>
<option value="movie">Фильмы</option>
<option value="series">Сериалы</option>
<option value="game">Игры</option>
</select>
</div>
<div>
<label className="block text-campfire-light mb-2">Жанр</label>
<select
value={filters.genre}
onChange={(e) => handleFilterChange('genre', e.target.value)}
className="w-full bg-campfire-dark border border-campfire-ash/30 rounded-lg p-2 text-campfire-light"
>
<option value="">Все</option>
<option value="action">Боевик</option>
<option value="comedy">Комедия</option>
<option value="drama">Драма</option>
<option value="horror">Ужасы</option>
<option value="sci-fi">Фантастика</option>
</select>
</div>
<div>
<label className="block text-campfire-light mb-2">Год</label>
<select
value={filters.year}
onChange={(e) => handleFilterChange('year', e.target.value)}
className="w-full bg-campfire-dark border border-campfire-ash/30 rounded-lg p-2 text-campfire-light"
>
<option value="">Все</option>
{Array.from({ length: 24 }, (_, i) => 2024 - i).map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
<div>
<label className="block text-campfire-light mb-2">Сортировка</label>
<select
value={filters.sort}
onChange={(e) => handleFilterChange('sort', e.target.value)}
className="w-full bg-campfire-dark border border-campfire-ash/30 rounded-lg p-2 text-campfire-light"
>
<option value="-created">Сначала новые</option>
<option value="created">Сначала старые</option>
<option value="-average_rating">По рейтингу</option>
<option value="title">По названию</option>
</select>
</div>
</div>
)}
</div>
{/* Список медиа */}
<div className="space-y-4">
{media.map((item) => (
<Link
key={item.id}
to={`/media/${item.path}`}
className="block bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg p-4"
>
<div className="flex items-center space-x-4">
<img
src={getFileUrl(item, 'poster')}
alt={item.title}
className="w-16 h-24 rounded-lg object-cover"
/>
<div className="flex-1">
<h3 className="text-campfire-light font-semibold mb-1">
{item.title}
</h3>
<div className="flex items-center space-x-2 text-sm">
<div className="flex items-center text-campfire-amber">
<FaStar className="mr-1" />
<span>{item.average_rating?.toFixed(1) || '0.0'}</span>
</div>
<span className="text-campfire-ash">
{item.review_count || 0} обзоров
</span>
</div>
<p className="text-campfire-ash text-sm mt-1">
{item.type === 'movie' ? 'Фильм' : item.type === 'series' ? 'Сериал' : 'Игра'} {item.year}
</p>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
);
};
export default MobileCatalog;

View File

@ -0,0 +1,227 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { getLatestReviews, listMedia, listUsersRankedByReviews, getFileUrl, getMediaCount, getReviewsCount } from '../../services/pocketbaseService';
import { FaStar, FaHeart, FaCrown, FaMedal } from 'react-icons/fa';
import GridMotionMobile from '../components/GridMotionMobile';
import CountUp from '../../components/reactbits/TextAnimations/CountUp/CountUp';
import { motion, AnimatePresence } from 'framer-motion';
import RotatingText from '../../components/reactbits/TextAnimations/RotatingText/RotatingText';
const MobileHome = () => {
const [popularMedia, setPopularMedia] = useState([]);
const [latestReviews, setLatestReviews] = useState([]);
const [topUsers, setTopUsers] = useState([]);
const [stats, setStats] = useState({ mediaCount: 0, reviewsCount: 0 });
const [posters, setPosters] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isDataReady, setIsDataReady] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const [mediaData, reviewsData, usersData, mediaCount, reviewsCount] = await Promise.all([
listMedia(null, 1, 40, null, false, true, '-review_count'),
getLatestReviews(5),
listUsersRankedByReviews(3),
getMediaCount(),
getReviewsCount()
]);
setPopularMedia(mediaData?.data || []);
setLatestReviews(reviewsData || []);
setTopUsers(usersData || []);
setStats({
mediaCount: parseInt(mediaCount) || 0,
reviewsCount: parseInt(reviewsCount) || 0
});
setPosters((mediaData?.data || []).filter(item => item.poster).map(item => getFileUrl(item, 'poster')));
setIsDataReady(true);
setLoading(false);
console.log(mediaData?.data)
} catch (err) {
setError('Не удалось загрузить данные');
setLoading(false);
}
};
fetchData();
}, []);
if (loading || !isDataReady) {
return (
<div className="min-h-screen bg-campfire-dark flex flex-col justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber mb-4"></div>
<span className="text-campfire-light">Загрузка...</span>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark flex items-center justify-center p-4">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
// Для GridMotionMobile: минимум 48 постеров
const minPosters = 48;
let postersForGrid = posters;
console.log(posters)
if (posters.length < minPosters) {
const placeholder = '/placeholder-poster.jpg';
postersForGrid = [
...posters,
...Array(minPosters - posters.length).fill(placeholder)
];
}
return (
<div className="min-h-screen bg-campfire-dark pb-8">
{/* GridMotion + статистика */}
<section className="relative h-[500px] mb-8 overflow-hidden">
<div className="absolute inset-0 z-0 pointer-events-none">
<GridMotionMobile items={postersForGrid} gradientColor="rgba(251, 191, 36, 0.08)" />
</div>
<div className="absolute inset-0 from-campfire-dark/0 via-campfire-dark/60 to-campfire-dark z-10"></div>
<div className="relative z-20 flex flex-col items-center justify-center h-full px-4">
<div className="text-center max-w-md mx-auto">
<h2 className="text-xl font-bold text-campfire-light mb-4 drop-shadow-lg">
<div className="flex flex-col items-center justify-center gap-2">
<span>Составляй рецензии на</span>
<div className="w-28 text-center">
<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 className="flex flex-col items-center justify-center w-full max-w-xs mx-auto">
<div className="bg-campfire-darker/80 backdrop-blur-md p-4 rounded-lg text-center shadow-md mb-4 w-full">
<h3 className="text-campfire-light text-xs mb-1">Медиа в каталоге</h3>
<CountUp to={stats.mediaCount} className="text-xl font-bold text-campfire-amber" duration={2} start={0} />
</div>
<div className="bg-campfire-darker/80 backdrop-blur-md p-4 rounded-lg text-center shadow-md w-full">
<h3 className="text-campfire-light text-xs mb-1">Рецензий написано</h3>
<CountUp to={stats.reviewsCount} className="text-xl font-bold text-campfire-amber" duration={2} start={0} />
</div>
</div>
</div>
</div>
</section>
{/* Топ рецензенты */}
<section className="px-4 mb-8">
<h2 className="text-xl font-bold text-campfire-light mb-4">Топ рецензенты</h2>
<div className="flex justify-center items-end gap-4">
{topUsers.map((user, idx) => (
<div key={user.id} className="flex flex-col items-center w-1/3">
<div className={`relative ${idx === 0 ? 'scale-110' : ''}`}>
<div className={`w-20 h-20 rounded-full overflow-hidden border-2 ${idx === 0 ? 'border-campfire-amber' : idx === 1 ? 'border-campfire-ash/30' : 'border-campfire-ember/30'} mb-2`}>
<img
src={getFileUrl(user, 'profile_picture') || '/default-avatar.png'}
alt={user.username}
className="w-full h-full object-cover"
/>
</div>
<div className={`absolute -top-2 -right-2 w-6 h-6 rounded-full flex items-center justify-center ${idx === 0 ? 'bg-campfire-amber text-campfire-dark' : 'bg-campfire-ash/30 text-campfire-light'}`}>
<span className="font-bold">{idx + 1}</span>
</div>
</div>
<Link to={`/profile/${user.login}`} className="text-campfire-light hover:text-campfire-amber font-medium text-sm">
{user.username}
</Link>
<span className="text-campfire-ash text-xs">{user.review_count} рецензий</span>
</div>
))}
</div>
<div className="text-center mt-4">
<Link to="/rating" className="text-campfire-amber hover:underline text-sm">Посмотреть рейтинг</Link>
</div>
</section>
{/* Последние обзоры */}
<section className="px-4 mb-8">
<h2 className="text-xl font-bold text-campfire-light mb-4">Последние обзоры</h2>
<div className="space-y-4">
{latestReviews.map((review) => (
<Link
key={review.id}
to={`/media/${review.expand?.media_id?.path}`}
className="block bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4 hover:bg-campfire-charcoal/30 transition-colors"
>
<div className="flex items-start gap-4">
<img
src={getFileUrl(review.expand?.media_id, 'poster')}
alt={review.expand?.media_id?.title}
className="w-16 h-24 object-cover rounded-lg"
/>
<div className="flex-1">
<h3 className="text-base font-semibold text-campfire-light mb-1">
{review.expand?.media_id?.title}
</h3>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center text-campfire-amber">
<FaStar className="mr-1" />
<span>{review.rating}</span>
</div>
<span className="text-campfire-ash"></span>
<div className="flex items-center gap-4 text-campfire-ash text-sm">
<div className="flex items-center">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<span></span>
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
</div>
<p className="text-campfire-light/80 line-clamp-2">
{review.text}
</p>
</div>
</div>
</Link>
))}
</div>
</section>
{/* Популярные медиа */}
<section className="px-4 mb-8">
<h2 className="text-xl font-bold text-campfire-light mb-4">Популярные медиа</h2>
<div className="grid grid-cols-2 gap-4">
{popularMedia.filter(media => media?.path).map((media) => (
<Link
key={media.id}
to={`/media/${media.path}`}
className="block bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg overflow-hidden hover:bg-campfire-charcoal/30 transition-colors"
>
<img
src={getFileUrl(media, 'poster')}
alt={media.title}
className="w-full aspect-[2/3] object-cover"
/>
<div className="p-3">
<h3 className="text-campfire-light font-semibold line-clamp-1">
{media.title}
</h3>
<div className="flex items-center text-campfire-amber mt-1">
<FaStar className="mr-1" />
<span>{media.average_rating?.toFixed(1) || '0.0'}</span>
</div>
</div>
</Link>
))}
</div>
</section>
</div>
);
};
export default MobileHome;

View File

@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { getUsers } from '../../services/pocketbaseService';
import { motion } from 'framer-motion';
import MobileLogo from '../components/MobileLogo';
import { FiHome } from 'react-icons/fi';
const MobileLogin = () => {
const [loginInput, setLoginInput] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { user, userProfile, loading: authLoading, login, isInitialized } = useAuth();
useEffect(() => {
if (!authLoading && isInitialized) {
if (user && userProfile) {
navigate(`/profile/${userProfile.login}`);
}
}
}, [user, userProfile, authLoading, isInitialized, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(loginInput, password);
if (userProfile) {
navigate(`/profile/${userProfile.login}`);
}
} catch (err) {
let message = '';
if (err?.message?.includes('Неверный логин или пароль') || err?.message?.includes('Failed to authenticate') || err?.message?.includes('Invalid login credentials')) {
try {
const users = await getUsers(1, 50);
if (users && users.length > 0) {
const randomUser = users[Math.floor(Math.random() * users.length)];
message = `Неверный логин или пароль. Возможно, вы имели в виду: ${randomUser.username}`;
} else {
message = 'Неверный логин или пароль.';
}
} catch {
message = 'Неверный логин или пароль.';
}
} else if (err?.message?.toLowerCase().includes('user') && err?.message?.toLowerCase().includes('not found')) {
message = 'Пользователь не найден.';
} else {
message = err.message || 'Произошла ошибка при входе.';
}
setError(message);
} finally {
setLoading(false);
}
};
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: "easeOut"
}
}
};
return (
<div className="min-h-screen bg-campfire-dark flex flex-col justify-center items-center p-4">
<div className="w-full flex flex-col items-center mb-6">
<MobileLogo className="w-20 h-20 mb-2" />
<Link to="/" className="flex items-center gap-2 text-campfire-amber hover:text-campfire-ember text-sm font-medium mt-2">
<FiHome /> На главную
</Link>
</div>
<motion.div
className="w-full max-w-md"
variants={containerVariants}
initial="hidden"
animate="visible"
>
<div className="bg-campfire-charcoal rounded-lg shadow-lg p-6 border border-campfire-ash/20">
<h1 className="text-2xl font-bold text-campfire-light mb-6 text-center">Вход</h1>
{error && (
<div className="mb-4 p-3 bg-status-error/20 text-status-error rounded-lg text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="login" className="block text-sm font-medium text-campfire-light mb-1">
Логин
</label>
<input
type="text"
id="login"
value={loginInput}
onChange={(e) => setLoginInput(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="Введите логин"
required
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-campfire-light mb-1">
Пароль
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={loading}
className={`btn-primary w-full ${loading ? "opacity-80 cursor-not-allowed" : ""} transition-colors duration-200`}
>
{loading ? (
<span className="inline-flex items-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Входим...
</span>
) : (
"Войти"
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-campfire-ash">
Еще нет аккаунта?{" "}
<Link
to="/auth/register"
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors"
>
Зарегистрироваться
</Link>
</div>
</div>
</motion.div>
</div>
);
};
export default MobileLogin;

View File

@ -0,0 +1,273 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { getMediaByPath, getReviewsByMediaId, getFileUrl, likeReview, unlikeReview } from '../../services/pocketbaseService';
import { FaStar, FaHeart, FaRegHeart, FaShare, FaArrowLeft } from 'react-icons/fa';
import MobileReviewForm from '../components/MobileReviewForm';
const MobileMedia = () => {
const { path } = useParams();
const { userProfile } = useAuth();
const [media, setMedia] = useState(null);
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showReviewForm, setShowReviewForm] = useState(false);
const [activeTab, setActiveTab] = useState('reviews');
useEffect(() => {
window.scrollTo(0, 0);
fetchData();
}, [path]);
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const [mediaData, reviewsData] = await Promise.all([
getMediaByPath(path),
getReviewsByMediaId(path)
]);
setMedia(mediaData);
setReviews(reviewsData);
} catch (err) {
console.error('Error fetching media data:', err);
setError('Не удалось загрузить данные медиа.');
} finally {
setLoading(false);
}
};
const handleLike = async (reviewId) => {
if (!userProfile) return;
try {
const review = reviews.find(r => r.id === reviewId);
if (review.likes?.includes(userProfile.id)) {
await unlikeReview(reviewId);
review.likes = review.likes.filter(id => id !== userProfile.id);
} else {
await likeReview(reviewId);
review.likes = [...(review.likes || []), userProfile.id];
}
setReviews([...reviews]);
} catch (err) {
console.error('Error handling like:', err);
}
};
const handleShare = async () => {
try {
await navigator.share({
title: media.title,
text: `Посмотрите ${media.title} на Campfire Critics!`,
url: window.location.href
});
} catch (err) {
console.error('Error sharing:', err);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
if (!media) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
Медиа не найдено
</div>
</div>
);
}
return (
<div className="min-h-screen bg-campfire-dark">
{/* Header */}
<div className="relative h-64">
<img
src={getFileUrl(media, 'banner')}
alt={media.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark to-transparent" />
<div className="absolute inset-0 flex items-end p-4">
<div className="flex items-center space-x-4">
<Link to="/" className="text-campfire-light hover:text-campfire-amber">
<FaArrowLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-bold text-campfire-light">{media.title}</h1>
</div>
</div>
</div>
{/* Content */}
<div className="px-4 py-6">
{/* Media Info */}
<div className="flex space-x-4 mb-6">
<img
src={getFileUrl(media, 'poster')}
alt={media.title}
className="w-24 h-36 object-cover rounded-lg"
/>
<div className="flex-grow">
<div className="flex items-center space-x-2 mb-2">
<FaStar className="text-campfire-amber" />
<span className="text-campfire-light font-semibold">
{media.average_rating?.toFixed(1) || '0.0'}
</span>
<span className="text-campfire-ash">
({media.review_count || 0} обзоров)
</span>
</div>
<p className="text-campfire-ash text-sm line-clamp-3">{media.description}</p>
</div>
</div>
{/* Tabs */}
<div className="flex space-x-2 mb-6">
<button
onClick={() => setActiveTab('reviews')}
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium ${
activeTab === 'reviews'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal/20 text-campfire-ash'
}`}
>
Рецензии
</button>
<button
onClick={() => setActiveTab('about')}
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium ${
activeTab === 'about'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal/20 text-campfire-ash'
}`}
>
О медиа
</button>
</div>
{/* Tab Content */}
{activeTab === 'reviews' ? (
<div className="space-y-6">
{userProfile && (
<button
onClick={() => setShowReviewForm(!showReviewForm)}
className="w-full py-3 bg-campfire-amber text-campfire-dark font-semibold rounded-xl hover:bg-campfire-amber/90"
>
{showReviewForm ? 'Отмена' : 'Написать рецензию'}
</button>
)}
{showReviewForm && (
<MobileReviewForm
mediaId={media.id}
onSuccess={() => {
setShowReviewForm(false);
fetchData();
}}
onCancel={() => setShowReviewForm(false)}
/>
)}
{reviews.map((review) => (
<div key={review.id} className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-xl p-4">
<div className="flex items-center space-x-3 mb-3">
<Link to={`/profile/${review.expand.user_id.login}`}>
<img
src={getFileUrl(review.expand.user_id, 'profile_picture') || 'https://via.placeholder.com/150'}
alt={review.expand.user_id.username}
className="w-10 h-10 rounded-full object-cover border border-campfire-ash/30"
/>
</Link>
<div className="flex-grow">
<Link to={`/profile/${review.expand.user_id.login}`} className="text-campfire-light font-semibold hover:text-campfire-amber">
{review.expand.user_id.username}
</Link>
<div className="flex items-center space-x-2 text-sm">
<FaStar className="text-campfire-amber" />
<span className="text-campfire-light">{review.rating}</span>
<span className="text-campfire-ash">
{new Date(review.created).toLocaleDateString()}
</span>
</div>
</div>
</div>
<div className="text-campfire-light mb-3" dangerouslySetInnerHTML={{ __html: review.content }} />
<div className="flex items-center space-x-4">
<button
onClick={() => handleLike(review.id)}
className="flex items-center space-x-1 text-campfire-ash hover:text-campfire-amber"
>
{review.likes?.includes(userProfile?.id) ? (
<FaHeart className="text-campfire-amber" />
) : (
<FaRegHeart />
)}
<span>{review.likes?.length || 0}</span>
</button>
<button
onClick={handleShare}
className="flex items-center space-x-1 text-campfire-ash hover:text-campfire-amber"
>
<FaShare />
</button>
</div>
</div>
))}
</div>
) : (
<div className="space-y-4">
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-xl p-4">
<h3 className="text-campfire-light font-semibold mb-2">Описание</h3>
<p className="text-campfire-ash">{media.description}</p>
</div>
{media.genres && (
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-xl p-4">
<h3 className="text-campfire-light font-semibold mb-2">Жанры</h3>
<div className="flex flex-wrap gap-2">
{media.genres.map((genre) => (
<span
key={genre}
className="px-3 py-1 bg-campfire-amber/20 text-campfire-amber rounded-full text-sm"
>
{genre}
</span>
))}
</div>
</div>
)}
{media.release_date && (
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-xl p-4">
<h3 className="text-campfire-light font-semibold mb-2">Дата выхода</h3>
<p className="text-campfire-ash">
{new Date(media.release_date).toLocaleDateString()}
</p>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default MobileMedia;

View File

@ -0,0 +1,516 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { pb, getFileUrl } from '../../services/pocketbaseService';
import { FaStar, FaHeart, FaEdit, FaTrash, FaFire } from 'react-icons/fa';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../../contexts/AuthContext';
import RatingChart from '../../components/reviews/RatingChart';
import ReviewForm from '../components/MobileReviewForm';
import Modal from '../../components/common/Modal';
const MobileMediaOverviewPage = () => {
const { path } = useParams();
const { user } = useAuth();
const navigate = useNavigate();
const [media, setMedia] = useState(null);
const [reviews, setReviews] = useState([]);
const [userReview, setUserReview] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeTab, setActiveTab] = useState('overview');
const [selectedSeasonId, setSelectedSeasonId] = useState(null);
const [seasons, setSeasons] = useState([]);
const [selectedSeasonDetails, setSelectedSeasonDetails] = useState(null);
const [isReviewFormOpen, setIsReviewFormOpen] = useState(false);
const [isReviewExpanded, setIsReviewExpanded] = useState({});
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
// Получаем данные медиа
const mediaData = await pb.collection('media').getFirstListItem(`path="${path}"`, {
expand: 'poster,backdrop'
});
// Получаем рецензии
const reviewsData = await pb.collection('reviews').getList(1, 50, {
filter: `media_id="${mediaData.id}"`,
sort: '-created',
expand: 'user_id'
});
// Если это сериал или аниме, получаем сезоны
if (mediaData.type === 'tv' || mediaData.type === 'anime') {
const seasonsData = await pb.collection('seasons').getList(1, 50, {
filter: `media_id="${mediaData.id}"`,
sort: 'season_number'
});
setSeasons(seasonsData.items);
}
// Получаем рецензию пользователя, если он авторизован
if (user) {
try {
const userReviewData = await pb.collection('reviews').getFirstListItem(
`user_id="${user.id}" && media_id="${mediaData.id}"`
);
setUserReview(userReviewData);
} catch (err) {
// Если рецензия не найдена, это нормально
setUserReview(null);
}
}
setMedia(mediaData);
setReviews(reviewsData.items || []);
} catch (err) {
console.error('Error fetching media data:', err);
if (err.status === 404) {
setError('Медиа не найдено');
navigate('/404');
} else {
setError('Не удалось загрузить данные');
}
} finally {
setLoading(false);
}
};
fetchData();
}, [path, user, navigate]);
// Функция для загрузки деталей сезона
const loadSeasonDetails = async (seasonId) => {
try {
const seasonData = await pb.collection('seasons').getOne(seasonId);
setSelectedSeasonDetails(seasonData);
} catch (err) {
console.error('Error loading season details:', err);
}
};
// Обработчик изменения сезона
useEffect(() => {
if (selectedSeasonId) {
loadSeasonDetails(selectedSeasonId);
} else {
setSelectedSeasonDetails(null);
}
}, [selectedSeasonId]);
// Функция для расчета средних оценок по характеристикам
const calculateAverageRatings = (reviews) => {
if (!reviews || !Array.isArray(reviews) || reviews.length === 0) {
return null;
}
const categories = [
'plot', 'characters', 'visuals', 'soundtrack', 'acting', 'direction',
'pacing', 'dialogue', 'world_building', 'animation', 'art_style'
];
const averages = {};
categories.forEach(category => {
const validRatings = reviews
.map(review => review.ratings?.[category])
.filter(rating => rating !== undefined && rating !== null);
if (validRatings.length > 0) {
averages[category] = validRatings.reduce((sum, rating) => sum + rating, 0) / validRatings.length;
}
});
return averages;
};
if (loading) {
return (
<div className="min-h-screen bg-campfire-dark flex items-center justify-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 flex items-center justify-center p-4">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
const isOwnMedia = user && media && user.id === media.created_by;
const averageRatings = calculateAverageRatings(reviews);
const renderFireRating = (rating) => {
const maxFires = 10;
const fires = [];
const normalizedRating = Math.round((rating / 10) * maxFires);
for (let i = 0; i < maxFires; i++) {
fires.push(
<FaFire
key={i}
className={`text-lg ${
i < normalizedRating ? 'text-campfire-amber' : 'text-campfire-ash/30'
}`}
/>
);
}
return fires;
};
return (
<div className="min-h-screen bg-campfire-dark">
{/* Media Header */}
<div className="relative h-[400px] mb-8">
<div className="absolute inset-0">
<img
src={getFileUrl(media, 'backdrop') || getFileUrl(media, 'poster')}
alt={media.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark via-campfire-dark/80 to-transparent"></div>
</div>
<div className="absolute bottom-0 left-0 right-0 p-6">
<div className="flex items-end gap-4">
<img
src={getFileUrl(media, 'poster')}
alt={media.title}
className="w-24 h-36 object-cover rounded-lg shadow-lg"
/>
<div className="flex-1">
<h1 className="text-2xl font-bold text-campfire-light mb-2">
{media.title}
</h1>
<div className="flex items-center gap-4 text-campfire-ash text-sm">
<div className="flex items-center">
<FaStar className="text-campfire-amber mr-1" />
<span>{media.average_rating?.toFixed(1) || '0.0'}</span>
</div>
<span></span>
<span>{media.review_count || 0} рецензий</span>
<span></span>
<span>{media.type}</span>
</div>
</div>
</div>
</div>
</div>
{/* Seasons Navigation (if applicable) */}
{(media.type === 'tv' || media.type === 'anime') && seasons.length > 0 && (
<div className="px-4 mb-8">
<div className="flex flex-wrap gap-2">
<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>
{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}
</button>
))}
</div>
</div>
)}
{/* Selected Season Details */}
{selectedSeasonDetails && (
<div className="px-4 mb-8">
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4">
<h3 className="text-xl font-bold text-campfire-light mb-2">
Сезон {selectedSeasonDetails.season_number}
</h3>
{selectedSeasonDetails.overview && (
<p className="text-campfire-light/80 mb-4">{selectedSeasonDetails.overview}</p>
)}
<div className="flex items-center gap-4 text-campfire-ash text-sm">
<div className="flex items-center">
<FaStar className="text-campfire-amber mr-1" />
<span>{selectedSeasonDetails.average_rating?.toFixed(1) || '0.0'}</span>
</div>
<span></span>
<span>{selectedSeasonDetails.review_count || 0} рецензий</span>
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="px-4 mb-8">
<div className="flex gap-4 border-b border-campfire-ash/30">
<button
onClick={() => setActiveTab('overview')}
className={`pb-2 px-1 ${
activeTab === 'overview'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Обзор
</button>
<button
onClick={() => setActiveTab('reviews')}
className={`pb-2 px-1 ${
activeTab === 'reviews'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Рецензии
</button>
</div>
</div>
{/* Content */}
<div className="px-4">
<AnimatePresence mode="wait">
{activeTab === 'overview' ? (
<motion.div
key="overview"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
>
<div>
<h2 className="text-xl font-bold text-campfire-light mb-2">Описание</h2>
<p className="text-campfire-light/80">{media.overview}</p>
</div>
{media.release_date && (
<div>
<h2 className="text-xl font-bold text-campfire-light mb-2">Дата выхода</h2>
<p className="text-campfire-light/80">
{new Date(media.release_date).toLocaleDateString()}
</p>
</div>
)}
{/* График средних оценок */}
{(() => {
if (averageRatings && Object.keys(averageRatings).length > 0) {
return (
<div className="mt-6">
<h3 className="text-campfire-light font-medium mb-4">Средние оценки</h3>
<RatingChart
ratings={averageRatings}
labels={media.characteristics}
/>
</div>
);
}
return null;
})()}
</motion.div>
) : (
<motion.div
key="reviews"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-4"
>
{/* Review Form */}
{user ? (
<div className="mb-8">
{userReview ? (
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4">
<h3 className="text-lg font-semibold text-campfire-light mb-2">Ваша рецензия</h3>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center text-campfire-amber">
<FaStar className="mr-1" />
<span>{userReview.rating}</span>
</div>
<span className="text-campfire-ash"></span>
<span className="text-campfire-ash text-sm">
{new Date(userReview.created).toLocaleDateString()}
</span>
</div>
<p className="text-campfire-light/80">{userReview.text}</p>
<button
onClick={() => setIsReviewFormOpen(true)}
className="mt-4 text-campfire-amber hover:underline"
>
Редактировать
</button>
</div>
) : (
<button
onClick={() => setIsReviewFormOpen(true)}
className="w-full py-3 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/80 transition-colors"
>
Написать рецензию
</button>
)}
</div>
) : (
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4 text-center">
<p className="text-campfire-light">
<Link to="/auth/login" className="text-campfire-amber hover:underline">
Войдите
</Link>
, чтобы оставить рецензию
</p>
</div>
)}
{/* Reviews List */}
{reviews && Array.isArray(reviews) && reviews.length > 0 ? (
reviews.map((review) => (
<div
key={review.id}
className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4"
>
<div className="flex items-start gap-4">
<img
src={getFileUrl(review.expand?.user_id, 'profile_picture') || '/default-avatar.png'}
alt={review.expand?.user_id?.username}
className="w-12 h-12 rounded-full object-cover"
/>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<Link
to={`/profile/${review.expand?.user_id?.login}`}
className="text-campfire-light font-medium hover:text-campfire-amber"
>
{review.expand?.user_id?.username}
</Link>
<div className="flex items-center text-campfire-amber">
<FaFire className="mr-1" />
<span>{review.ratings?.overall?.toFixed(1) || '0.0'}</span>
</div>
</div>
<p className={`text-campfire-light/80 ${!isReviewExpanded[review.id] && 'line-clamp-3'}`}>
{review.text}
</p>
{review.text && review.text.length > 200 && (
<button
onClick={() => setIsReviewExpanded(prev => ({
...prev,
[review.id]: !prev[review.id]
}))}
className="mt-2 text-campfire-amber hover:underline"
>
{isReviewExpanded[review.id] ? 'Свернуть' : 'Развернуть'}
</button>
)}
<div className="flex items-center gap-4 mt-2 text-campfire-ash text-sm">
<div className="flex items-center">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<span></span>
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
))
) : (
<div className="text-center py-8 text-campfire-light">
Пока нет рецензий
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Review Form Modal */}
<Modal
isOpen={isReviewFormOpen}
onClose={() => setIsReviewFormOpen(false)}
title={userReview ? 'Редактировать рецензию' : 'Написать рецензию'}
>
<ReviewForm
mediaId={media.id}
seasonId={selectedSeasonId}
mediaType={media.type}
progress_type={media.progress_type}
characteristics={media.characteristics}
onSubmit={async (data) => {
try {
if (userReview) {
await pb.collection('reviews').update(userReview.id, data);
} else {
await pb.collection('reviews').create({
...data,
user_id: user.id,
media_id: media.id
});
}
setIsReviewFormOpen(false);
// Обновляем данные
const reviewsData = await pb.collection('reviews').getList(1, 50, {
filter: `media_id="${media.id}"`,
sort: '-created',
expand: 'user_id'
});
setReviews(reviewsData.items);
} catch (err) {
console.error('Error saving review:', err);
}
}}
onEdit={async (reviewId, data) => {
try {
await pb.collection('reviews').update(reviewId, data);
setIsReviewFormOpen(false);
// Обновляем данные
const reviewsData = await pb.collection('reviews').getList(1, 50, {
filter: `media_id="${media.id}"`,
sort: '-created',
expand: 'user_id'
});
setReviews(reviewsData.items);
} catch (err) {
console.error('Error updating review:', err);
}
}}
onDelete={async () => {
try {
await pb.collection('reviews').delete(userReview.id);
setIsReviewFormOpen(false);
setUserReview(null);
// Обновляем данные
const reviewsData = await pb.collection('reviews').getList(1, 50, {
filter: `media_id="${media.id}"`,
sort: '-created',
expand: 'user_id'
});
setReviews(reviewsData.items);
} catch (err) {
console.error('Error deleting review:', err);
}
}}
existingReview={userReview}
seasons={seasons}
selectedSeasonId={selectedSeasonId}
/>
</Modal>
</div>
);
};
export default MobileMediaOverviewPage;

View File

@ -0,0 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
const MobileNotFound = () => {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center">
<h1 className="text-4xl font-bold text-campfire-amber mb-4">404</h1>
<p className="text-xl text-campfire-light mb-8">
Страница не найдена
</p>
<Link
to="/"
className="px-6 py-3 bg-campfire-amber text-campfire-dark rounded-lg font-semibold hover:bg-campfire-amber/90 transition-colors"
>
Вернуться на главную
</Link>
</div>
);
};
export default MobileNotFound;

View File

@ -0,0 +1,390 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { pb, getFileUrl, getUserAchievements } from '../../services/pocketbaseService';
import { FaStar, FaHeart, FaEdit, FaSignOutAlt, FaPalette, FaTrophy, FaGripVertical } from 'react-icons/fa';
import { motion, AnimatePresence } from 'framer-motion';
import ShowcaseThemeModal from '../components/ShowcaseThemeModal';
import AchievementsModal from '../components/AchievementsModal';
import ShowcaseSortModal from '../components/ShowcaseSortModal';
import Squares from '../../components/reactbits/Backgrounds/Squares/Squares';
import Dither from '../../components/reactbits/Backgrounds/Dither/Dither';
const MobileProfile = () => {
const { login } = useParams();
const { user, userProfile, logout } = useAuth();
const navigate = useNavigate();
const [profile, setProfile] = useState(null);
const [reviews, setReviews] = useState([]);
const [achievements, setAchievements] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [isAchievementsModalOpen, setIsAchievementsModalOpen] = useState(false);
const [isSortModalOpen, setIsSortModalOpen] = useState(false);
const [showcaseTheme, setShowcaseTheme] = useState({
type: 'none',
background: '#1a1a1a',
squares: {
direction: 'down',
speed: 0.25,
borderColor: '#FFFFFF',
squareSize: 20,
hoverFillColor: '#FF6A00',
backgroundColor: '#1a1a1a'
},
dither: {
waveSpeed: 0.05,
waveFrequency: 3,
waveAmplitude: 0.3,
waveColor: [0.4, 0.4, 0.4],
colorNum: 4,
pixelSize: 2,
enableMouseInteraction: false,
mouseRadius: 1
}
});
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
// Получаем данные пользователя
const userData = await pb.collection('users').getFirstListItem(`login="${login}"`, {
expand: 'showcase,showcase.media_id'
});
// Получаем рецензии пользователя
const reviewsData = await pb.collection('reviews').getList(1, 10, {
filter: `user_id="${userData.id}"`,
sort: '-created',
expand: 'media_id'
});
// Получаем достижения пользователя
const achievementsData = await getUserAchievements(userData.id);
// Парсим тему витрины
if (userData.showcase_theme) {
try {
const parsedTheme = typeof userData.showcase_theme === 'string'
? JSON.parse(userData.showcase_theme)
: userData.showcase_theme;
setShowcaseTheme(parsedTheme);
} catch (parseError) {
console.warn('Ошибка при парсинге showcase_theme:', parseError);
}
}
setProfile(userData);
setReviews(reviewsData.items);
setAchievements(achievementsData || []);
} catch (err) {
console.error('Error fetching profile data:', err);
setError('Не удалось загрузить данные профиля');
} finally {
setLoading(false);
}
};
fetchData();
}, [login]);
const handleLogout = async () => {
try {
await logout();
navigate('/');
} catch (err) {
console.error('Error logging out:', err);
}
};
const handleThemeChange = async (theme) => {
try {
await pb.collection('users').update(profile.id, {
showcase_theme: JSON.stringify({
...showcaseTheme,
type: theme
})
});
setShowcaseTheme(prev => ({
...prev,
type: theme
}));
setIsThemeModalOpen(false);
} catch (err) {
console.error('Error updating theme:', err);
}
};
const handleSaveOrder = async (selectedReviewIds) => {
try {
await pb.collection('users').update(profile.id, {
showcase: selectedReviewIds
});
// Обновляем локальное состояние
setProfile(prev => ({
...prev,
showcase: selectedReviewIds
}));
setIsSortModalOpen(false);
} catch (err) {
console.error('Error saving showcase order:', err);
}
};
if (loading) {
return (
<div className="min-h-screen bg-campfire-dark flex items-center justify-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 flex items-center justify-center p-4">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
);
}
const isOwnProfile = user && profile && user.id === profile.id;
return (
<motion.div
className="min-h-screen bg-campfire-dark"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{/* Profile Header */}
<div className="relative h-[300px] mb-8">
<div className="absolute inset-0">
<img
src={getFileUrl(profile, 'banner_picture')}
alt="Profile banner"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark via-campfire-dark/80 to-transparent"></div>
</div>
<div className="absolute bottom-0 left-0 right-0 p-6">
<div className="flex items-end gap-4">
<div className="relative">
<div className="w-24 h-24 rounded-full overflow-hidden border-4 border-campfire-dark">
<img
src={getFileUrl(profile, 'profile_picture') || '/default-avatar.png'}
alt={profile.username}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-campfire-light mb-1">
{profile.username}
</h1>
<div className="flex items-center gap-4 text-campfire-ash text-sm">
<div className="flex items-center">
<FaStar className="text-campfire-amber mr-1" />
<span>{profile.average_rating?.toFixed(1) || '0.0'}</span>
</div>
<span></span>
<span>{profile.review_count || 0} рецензий</span>
</div>
</div>
</div>
{isOwnProfile && (
<div className="flex flex-wrap gap-2 mt-4">
<Link
to="/profile/edit"
className="flex items-center gap-2 px-4 py-2 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/80 transition-colors"
>
<FaEdit />
<span>Редактировать</span>
</Link>
<button
onClick={() => setIsAchievementsModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-campfire-charcoal/50 text-campfire-light border border-campfire-ash/30 rounded-lg hover:bg-campfire-charcoal/70 transition-colors"
>
<FaTrophy />
<span>Достижения</span>
</button>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 bg-campfire-charcoal/50 text-campfire-light border border-campfire-ash/30 rounded-lg hover:bg-campfire-charcoal/70 transition-colors"
>
<FaSignOutAlt />
<span>Выйти</span>
</button>
</div>
)}
</div>
</div>
{/* Showcase Section */}
<section className="px-4 mb-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-campfire-light">Витрина рецензий</h2>
{isOwnProfile && (
<div className="flex gap-2">
<button
onClick={() => setIsThemeModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-campfire-charcoal/50 text-campfire-light border border-campfire-ash/30 rounded-lg hover:bg-campfire-charcoal/70 transition-colors"
>
<FaPalette />
<span>Тема</span>
</button>
<button
onClick={() => setIsSortModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-campfire-charcoal/50 text-campfire-light border border-campfire-ash/30 rounded-lg hover:bg-campfire-charcoal/70 transition-colors"
>
<FaGripVertical />
<span>Выбрать 3 рецензии</span>
</button>
</div>
)}
</div>
<div className="relative rounded-3xl overflow-hidden" style={{ backgroundColor: showcaseTheme.background }}>
{showcaseTheme.type === 'squares' && (
<div className="absolute inset-0 pointer-events-none" style={{ backgroundColor: showcaseTheme.squares.backgroundColor }}>
<Squares
direction={showcaseTheme.squares.direction}
speed={showcaseTheme.squares.speed}
borderColor={showcaseTheme.squares.borderColor}
squareSize={showcaseTheme.squares.squareSize}
hoverFillColor={showcaseTheme.squares.hoverFillColor}
/>
</div>
)}
{showcaseTheme.type === 'dither' && (
<div className="absolute inset-0 z-0" style={{ pointerEvents: showcaseTheme.dither.enableMouseInteraction ? 'auto' : 'none' }}>
<Dither
waveSpeed={showcaseTheme.dither.waveSpeed}
waveFrequency={showcaseTheme.dither.waveFrequency}
waveAmplitude={showcaseTheme.dither.waveAmplitude}
waveColor={showcaseTheme.dither.waveColor}
colorNum={showcaseTheme.dither.colorNum}
pixelSize={showcaseTheme.dither.pixelSize}
enableMouseInteraction={showcaseTheme.dither.enableMouseInteraction}
mouseRadius={showcaseTheme.dither.mouseRadius}
/>
</div>
)}
<div className="relative z-10 p-4">
{profile.expand?.showcase && profile.expand.showcase.length > 0 ? (
<div className="space-y-4">
{profile.expand.showcase.map(review => (
<Link
key={review.id}
to={`/media/${review.expand?.media_id?.path}`}
className="block bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4 hover:bg-campfire-charcoal/30 transition-colors"
>
<div className="flex items-start gap-4">
<img
src={getFileUrl(review.expand?.media_id, 'poster')}
alt={review.expand?.media_id?.title}
className="w-20 h-28 object-cover rounded-lg"
/>
<div className="flex-1">
<h3 className="text-lg font-semibold text-campfire-light mb-1">
{review.expand?.media_id?.title}
</h3>
<div className="flex items-center gap-4 text-campfire-ash text-sm">
<div className="flex items-center">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<span></span>
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
<p className="text-campfire-light/80 line-clamp-2">
{review.text}
</p>
</div>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-8 text-campfire-light">
{isOwnProfile ? (
<>
Ваша витрина пуста. <button onClick={() => setIsSortModalOpen(true)} className="text-campfire-amber hover:underline">Выберите</button> 3 лучшие рецензии!
</>
) : (
'У пользователя пока нет рецензий на витрине.'
)}
</div>
)}
</div>
</div>
</section>
{/* Reviews Section */}
<section className="px-4 mb-8">
<h2 className="text-xl font-bold text-campfire-light mb-4">Рецензии</h2>
<div className="space-y-4">
{reviews.map((review) => (
<Link
key={review.id}
to={`/media/${review.expand?.media_id?.path}`}
className="block bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-lg p-4 hover:bg-campfire-charcoal/30 transition-colors"
>
<div className="flex items-start gap-4">
<img
src={getFileUrl(review.expand?.media_id, 'poster')}
alt={review.expand?.media_id?.title}
className="w-20 h-28 object-cover rounded-lg"
/>
<div className="flex-1">
<h3 className="text-lg font-semibold text-campfire-light mb-1">
{review.expand?.media_id?.title}
</h3>
<div className="flex items-center gap-4 text-campfire-ash text-sm">
<div className="flex items-center">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<span></span>
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
<p className="text-campfire-light/80 line-clamp-2">
{review.text}
</p>
</div>
</div>
</Link>
))}
</div>
</section>
<ShowcaseThemeModal
isOpen={isThemeModalOpen}
onClose={() => setIsThemeModalOpen(false)}
currentTheme={showcaseTheme.type}
onSelectTheme={handleThemeChange}
/>
<AchievementsModal
isOpen={isAchievementsModalOpen}
onClose={() => setIsAchievementsModalOpen(false)}
userAchievements={achievements}
/>
<ShowcaseSortModal
isOpen={isSortModalOpen}
onClose={() => setIsSortModalOpen(false)}
reviews={reviews}
showcase={profile?.showcase || []}
onSave={handleSaveOrder}
/>
</motion.div>
);
};
export default MobileProfile;

View File

@ -0,0 +1,235 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { listMedia, listUsersRankedByReviews, listUsersRankedByLevel, getReviewsByLikes, getFileUrl } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
import { FaFire, FaStar, FaUsers, FaLevelUpAlt, FaMedal, FaHeart } from 'react-icons/fa';
const MobileRating = () => {
const { userProfile } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState('media-rating');
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 = 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);
}
};
useEffect(() => {
fetchData();
}, [userProfile]);
const renderMediaList = (mediaItems) => (
<div className="grid grid-cols-2 gap-x-4 gap-y-6 mb-8 px-4">
{mediaItems.map((mediaItem) => (
<Link to={`/media/${mediaItem.path}`} key={mediaItem.id} className="block">
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-2xl overflow-hidden">
<img
src={getFileUrl(mediaItem, 'poster')}
alt={mediaItem.title}
className="w-full aspect-[2/3] object-cover"
/>
<div className="p-3">
<h3 className="text-campfire-light font-semibold text-sm mb-1 line-clamp-1">
{mediaItem.title}
</h3>
<div className="flex items-center space-x-2 text-xs">
<div className="flex items-center text-campfire-amber">
<FaStar className="mr-1" />
<span>{mediaItem.average_rating?.toFixed(1) || '0.0'}</span>
</div>
<span className="text-campfire-ash">
{mediaItem.review_count || 0} обзоров
</span>
</div>
</div>
</div>
</Link>
))}
</div>
);
const renderUserList = (userItems, rankType) => (
<div className="space-y-4 px-4">
{userItems.map((user, index) => (
<Link to={`/profile/${user.login}`} key={user.id} className="block">
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-2xl shadow-md p-4 flex items-center mb-4">
<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://via.placeholder.com/150'}
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-campfire-light font-semibold">{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>
</div>
</Link>
))}
</div>
);
const renderReviewList = (reviews) => (
<div className="space-y-4 px-4">
{reviews.map((review, index) => (
<Link to={`/media/${review.expand.media_id.path}`} key={review.id} className="block">
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-2xl p-4">
<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-campfire-light font-semibold">{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 text-sm line-clamp-2" dangerouslySetInnerHTML={{ __html: review.content }} />
</div>
</Link>
))}
</div>
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<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 pb-4">
<h1 className="text-2xl font-bold mb-8 text-campfire-light text-center">Рейтинги</h1>
<div className="flex overflow-x-auto space-x-2 pb-4 mb-6 px-4">
<button
onClick={() => setActiveTab('media-rating')}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-full ${
activeTab === 'media-rating'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal/20 text-campfire-ash'
}`}
>
Медиа по рейтингу
</button>
<button
onClick={() => setActiveTab('media-reviews')}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-full ${
activeTab === 'media-reviews'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal/20 text-campfire-ash'
}`}
>
Медиа по рецензиям
</button>
<button
onClick={() => setActiveTab('reviews-likes')}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-full ${
activeTab === 'reviews-likes'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal/20 text-campfire-ash'
}`}
>
Рецензии по лайкам
</button>
<button
onClick={() => setActiveTab('users-reviews')}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-full ${
activeTab === 'users-reviews'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal/20 text-campfire-ash'
}`}
>
Пользователи по рецензиям
</button>
<button
onClick={() => setActiveTab('users-level')}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-full ${
activeTab === 'users-level'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-charcoal/20 text-campfire-ash'
}`}
>
Пользователи по уровню
</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 MobileRating;

View File

@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { signUp } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
import { FaEye, FaEyeSlash, FaUser, FaEnvelope, FaLock } from 'react-icons/fa';
import MobileLogo from '../components/MobileLogo';
import { FiHome } from 'react-icons/fi';
const MobileRegister = () => {
const navigate = useNavigate();
const { setUser } = useAuth();
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Пароли не совпадают');
return;
}
if (formData.username.length < 3 || formData.username.length > 20) {
setError('Имя пользователя должно быть от 3 до 20 символов');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) {
setError('Имя пользователя может содержать только буквы, цифры, дефис и подчеркивание');
return;
}
if (formData.password.length < 6) {
setError('Пароль должен быть не менее 6 символов');
return;
}
setLoading(true);
try {
const { user } = await signUp(formData.username, formData.password, formData.email);
setUser(user);
navigate('/');
} catch (err) {
if (err.message.includes('username')) {
setError('Это имя пользователя уже занято');
} else if (err.message.includes('email')) {
setError('Этот email уже зарегистрирован');
} else {
setError(err.message || 'Не удалось зарегистрироваться');
}
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-campfire-dark flex flex-col justify-center items-center p-4">
<div className="w-full flex flex-col items-center mb-6">
<MobileLogo className="w-20 h-20 mb-2" />
<Link to="/" className="flex items-center gap-2 text-campfire-amber hover:text-campfire-ember text-sm font-medium mt-2">
<FiHome /> На главную
</Link>
</div>
<div className="w-full max-w-md">
<div className="bg-campfire-charcoal/20 backdrop-blur-md border border-campfire-ash/30 rounded-xl p-6">
<h1 className="text-2xl font-bold text-campfire-light mb-6 text-center">Регистрация</h1>
{error && (
<div className="bg-status-error/20 text-status-error p-3 rounded-lg mb-4 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FaUser className="text-campfire-ash" />
</div>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Имя пользователя"
required
pattern="[a-zA-Z0-9_-]+"
title="Только буквы, цифры, дефис и подчеркивание"
className="w-full pl-10 pr-4 py-2 bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg text-campfire-light placeholder-campfire-ash focus:outline-none focus:border-campfire-amber"
/>
</div>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FaEnvelope className="text-campfire-ash" />
</div>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
required
className="w-full pl-10 pr-4 py-2 bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg text-campfire-light placeholder-campfire-ash focus:outline-none focus:border-campfire-amber"
/>
</div>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FaLock className="text-campfire-ash" />
</div>
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Пароль"
required
minLength="6"
className="w-full pl-10 pr-12 py-2 bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg text-campfire-light placeholder-campfire-ash focus:outline-none focus:border-campfire-amber"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<FaEyeSlash className="text-campfire-ash hover:text-campfire-light" />
) : (
<FaEye className="text-campfire-ash hover:text-campfire-light" />
)}
</button>
</div>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FaLock className="text-campfire-ash" />
</div>
<input
type={showConfirmPassword ? 'text' : 'password'}
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="Подтвердите пароль"
required
minLength="6"
className="w-full pl-10 pr-12 py-2 bg-campfire-charcoal/20 border border-campfire-ash/30 rounded-lg text-campfire-light placeholder-campfire-ash focus:outline-none focus:border-campfire-amber"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showConfirmPassword ? (
<FaEyeSlash className="text-campfire-ash hover:text-campfire-light" />
) : (
<FaEye className="text-campfire-ash hover:text-campfire-light" />
)}
</button>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-campfire-amber text-campfire-dark font-semibold rounded-lg hover:bg-campfire-amber/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-campfire-dark"></div>
</div>
) : (
'Зарегистрироваться'
)}
</button>
</form>
<div className="mt-4 text-center">
<p className="text-campfire-ash">
Уже есть аккаунт?{' '}
<Link to="/auth/login" className="text-campfire-amber hover:text-campfire-amber/90">
Войти
</Link>
</p>
</div>
</div>
</div>
</div>
);
};
export default MobileRegister;

View File

@ -0,0 +1,272 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { pb, getFileUrl } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
import { FaArrowLeft, FaStar, FaHeart, FaEdit, FaTrash } from 'react-icons/fa';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
const MobileReview = () => {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const [review, setReview] = useState(null);
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [loading, setLoading] = useState(true);
const [liked, setLiked] = useState(false);
useEffect(() => {
fetchReview();
}, [id]);
const fetchReview = async () => {
try {
const record = await pb.collection('reviews').getOne(id, {
expand: 'user_id,media_id'
});
setReview(record);
// Получаем комментарии отдельно
const comments = await pb.collection('comments').getList(1, 50, {
filter: `review_id = "${id}"`,
expand: 'user_id',
sort: '-created'
});
setComments(comments.items);
if (user) {
const likes = await pb.collection('likes').getList(1, 1, {
filter: `review_id = "${id}" && user_id = "${user.id}"`
});
setLiked(likes.items.length > 0);
}
} catch (error) {
console.error('Error fetching review:', error);
} finally {
setLoading(false);
}
};
const handleLike = async () => {
if (!user) {
navigate('/auth/login');
return;
}
try {
if (liked) {
const likes = await pb.collection('likes').getList(1, 1, {
filter: `review_id = "${id}" && user_id = "${user.id}"`
});
if (likes.items.length > 0) {
await pb.collection('likes').delete(likes.items[0].id);
}
} else {
await pb.collection('likes').create({
review_id: id,
user_id: user.id
});
}
setLiked(!liked);
fetchReview();
} catch (error) {
console.error('Error toggling like:', error);
}
};
const handleComment = async (e) => {
e.preventDefault();
if (!user) {
navigate('/auth/login');
return;
}
try {
await pb.collection('comments').create({
review_id: id,
user_id: user.id,
content: newComment
});
setNewComment('');
fetchReview();
} catch (error) {
console.error('Error adding comment:', error);
}
};
const handleDelete = async () => {
if (window.confirm('Вы уверены, что хотите удалить этот обзор?')) {
try {
await pb.collection('reviews').delete(id);
navigate(-1);
} catch (error) {
console.error('Error deleting review:', error);
}
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (!review) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center">
<h1 className="text-2xl font-bold text-campfire-amber mb-4">Обзор не найден</h1>
<Link
to="/"
className="px-6 py-3 bg-campfire-amber text-campfire-dark rounded-lg font-semibold hover:bg-campfire-amber/90 transition-colors"
>
Вернуться на главную
</Link>
</div>
);
}
const isOwnReview = user && user.id === review.user_id;
return (
<div className="px-4 py-4">
<button
onClick={() => navigate(-1)}
className="flex items-center text-campfire-light mb-6"
>
<FaArrowLeft className="mr-2" />
Назад
</button>
<div className="bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg overflow-hidden mb-6">
<div className="relative">
<img
src={getFileUrl(review.expand?.media_id, 'poster')}
alt={review.expand?.media_id?.title}
className="w-full h-64 object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-campfire-dark to-transparent p-4">
<h1 className="text-xl font-bold text-campfire-light">
{review.expand?.media_id?.title}
</h1>
</div>
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<img
src={getFileUrl(review.expand?.user_id, 'profile_picture')}
alt={review.expand?.user_id?.username}
className="w-8 h-8 rounded-full"
/>
<div>
<Link
to={`/profile/${review.expand?.user_id?.username}`}
className="text-campfire-light font-semibold"
>
{review.expand?.user_id?.username}
</Link>
<p className="text-campfire-ash text-sm">
{formatDistanceToNow(new Date(review.created), { addSuffix: true, locale: ru })}
</p>
</div>
</div>
{isOwnReview && (
<div className="flex space-x-2">
<Link
to={`/review/${review.id}/edit`}
className="p-2 text-campfire-light hover:text-campfire-amber"
>
<FaEdit />
</Link>
<button
onClick={handleDelete}
className="p-2 text-campfire-light hover:text-red-500"
>
<FaTrash />
</button>
</div>
)}
</div>
<div className="flex items-center space-x-4 mb-4">
<span className="text-campfire-amber">
<FaStar className="inline mr-1" />
{review.rating}/10
</span>
<button
onClick={handleLike}
className={`flex items-center space-x-1 ${
liked ? 'text-red-500' : 'text-campfire-light'
}`}
>
<FaHeart />
<span>{review.likes || 0}</span>
</button>
</div>
<p className="text-campfire-light whitespace-pre-wrap">
{review.content}
</p>
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-campfire-light">Комментарии</h2>
<form onSubmit={handleComment} className="flex space-x-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Написать комментарий..."
className="flex-1 px-4 py-2 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg text-campfire-light focus:outline-none focus:border-campfire-amber"
/>
<button
type="submit"
disabled={!newComment.trim()}
className="px-4 py-2 bg-campfire-amber text-campfire-dark rounded-lg font-semibold hover:bg-campfire-amber/90 transition-colors disabled:opacity-50"
>
Отправить
</button>
</form>
<div className="space-y-4">
{comments.map((comment) => (
<div
key={comment.id}
className="bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg p-4"
>
<div className="flex items-center space-x-2 mb-2">
<img
src={getFileUrl(comment.expand?.user_id, 'profile_picture')}
alt={comment.expand?.user_id?.username}
className="w-6 h-6 rounded-full"
/>
<div>
<Link
to={`/profile/${comment.expand?.user_id?.username}`}
className="text-campfire-light font-semibold text-sm"
>
{comment.expand?.user_id?.username}
</Link>
<p className="text-campfire-ash text-xs">
{formatDistanceToNow(new Date(comment.created), { addSuffix: true, locale: ru })}
</p>
</div>
</div>
<p className="text-campfire-light text-sm">
{comment.content}
</p>
</div>
))}
</div>
</div>
</div>
);
};
export default MobileReview;

View File

@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { pb } from '../../services/pocketbaseService';
import { getFileUrl } from '../../services/pocketbaseService';
import { FaSearch, FaTimes } from 'react-icons/fa';
const MobileSearch = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('all');
useEffect(() => {
if (searchQuery) {
performSearch();
}
}, [searchQuery, activeTab]);
const performSearch = async () => {
setLoading(true);
try {
let filter = '';
if (activeTab === 'media') {
filter = `title ~ "${searchQuery}"`;
} else if (activeTab === 'users') {
filter = `username ~ "${searchQuery}"`;
} else {
const [mediaResults, userResults] = await Promise.all([
pb.collection('media').getList(1, 10, {
filter: `title ~ "${searchQuery}"`
}),
pb.collection('users').getList(1, 10, {
filter: `username ~ "${searchQuery}"`
})
]);
setResults([...mediaResults.items, ...userResults.items]);
setLoading(false);
return;
}
const records = await pb.collection(activeTab === 'users' ? 'users' : 'media').getList(1, 20, {
filter: filter
});
setResults(records.items);
} catch (error) {
console.error('Error performing search:', error);
} finally {
setLoading(false);
}
};
const handleSearch = (e) => {
e.preventDefault();
setSearchParams({ q: searchQuery });
};
const clearSearch = () => {
setSearchQuery('');
setSearchParams({});
setResults([]);
};
return (
<div className="px-4 py-4">
<form onSubmit={handleSearch} className="mb-6">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск..."
className="w-full pl-10 pr-10 py-2 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg text-campfire-light focus:outline-none focus:border-campfire-amber"
/>
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-campfire-ash" />
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-campfire-ash hover:text-campfire-light"
>
<FaTimes />
</button>
)}
</div>
</form>
<div className="flex space-x-4 mb-6">
<button
onClick={() => setActiveTab('all')}
className={`flex-1 py-2 px-4 rounded-lg ${
activeTab === 'all'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-dark/50 text-campfire-light'
}`}
>
Все
</button>
<button
onClick={() => setActiveTab('media')}
className={`flex-1 py-2 px-4 rounded-lg ${
activeTab === 'media'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-dark/50 text-campfire-light'
}`}
>
Медиа
</button>
<button
onClick={() => setActiveTab('users')}
className={`flex-1 py-2 px-4 rounded-lg ${
activeTab === 'users'
? 'bg-campfire-amber text-campfire-dark'
: 'bg-campfire-dark/50 text-campfire-light'
}`}
>
Пользователи
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
) : results.length > 0 ? (
<div className="space-y-4">
{results.map((item) => (
<Link
key={item.id}
to={activeTab === 'users' ? `/profile/${item.username}` : `/media/${item.id}`}
className="flex items-center space-x-4 bg-campfire-dark/50 border border-campfire-ash/30 rounded-lg p-4"
>
{activeTab === 'users' ? (
<>
<img
src={getFileUrl(item, 'profile_picture')}
alt={item.username}
className="w-12 h-12 rounded-full object-cover"
/>
<div>
<h3 className="text-campfire-light font-semibold">{item.username}</h3>
<p className="text-campfire-ash text-sm">
{item.reviews_count || 0} обзоров
</p>
</div>
</>
) : (
<>
<img
src={getFileUrl(item, 'poster')}
alt={item.title}
className="w-12 h-12 rounded-lg object-cover"
/>
<div>
<h3 className="text-campfire-light font-semibold">{item.title}</h3>
<p className="text-campfire-ash text-sm">
{item.type === 'movie' ? 'Фильм' : item.type === 'series' ? 'Сериал' : 'Игра'}
</p>
</div>
</>
)}
</Link>
))}
</div>
) : searchQuery ? (
<div className="text-center py-8">
<p className="text-campfire-light">Ничего не найдено</p>
</div>
) : null}
</div>
);
};
export default MobileSearch;

62
src/mobile/routes.jsx Normal file
View File

@ -0,0 +1,62 @@
import React, { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import MobileLayout from './layout/MobileLayout';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { useAuth } from '../contexts/AuthContext';
const MobileHome = lazy(() => import('./pages/MobileHome'));
const MobileCatalog = lazy(() => import('./pages/MobileCatalog'));
const MobileProfile = lazy(() => import('./pages/MobileProfile'));
const MobileRating = lazy(() => import('./pages/MobileRating'));
const MobileLogin = lazy(() => import('./pages/MobileLogin'));
const MobileRegister = lazy(() => import('./pages/MobileRegister'));
const MobileMediaOverviewPage = lazy(() => import('./pages/MobileMediaOverviewPage'));
const MobileMediaForm = lazy(() => import('./components/MobileMediaForm'));
const MobileNotFound = lazy(() => import('./pages/MobileNotFound'));
const LazyLoad = ({ children }) => (
<Suspense fallback={<LoadingSpinner />}>{children}</Suspense>
);
const PrivateRoute = ({ children }) => {
const { user, isInitialized } = useAuth();
if (!isInitialized) {
return <LoadingSpinner />;
}
return user ? children : <Navigate to="/auth/login" />;
};
const MobileRoutes = () => {
return (
<Routes>
{/* Страницы авторизации */}
<Route path="/auth">
<Route path="login" element={<LazyLoad><MobileLogin /></LazyLoad>} />
<Route path="register" element={<LazyLoad><MobileRegister /></LazyLoad>} />
</Route>
{/* Все остальные страницы внутри MobileLayout */}
<Route path="/" element={<MobileLayout />}>
<Route index element={<LazyLoad><MobileHome /></LazyLoad>} />
<Route path="catalog" element={<LazyLoad><MobileCatalog /></LazyLoad>} />
<Route path="rating" element={<LazyLoad><MobileRating /></LazyLoad>} />
<Route path="media/:path" element={<LazyLoad><MobileMediaOverviewPage /></LazyLoad>} />
<Route path="profile" element={<PrivateRoute><LazyLoad><MobileProfile /></LazyLoad></PrivateRoute>} />
<Route path="profile/:login" element={<LazyLoad><MobileProfile /></LazyLoad>} />
<Route path="*" element={<LazyLoad><MobileNotFound /></LazyLoad>} />
</Route>
<Route
path="/media/new"
element={
<PrivateRoute>
<MobileMediaForm />
</PrivateRoute>
}
/>
</Routes>
);
};
export default MobileRoutes;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import MobileHome from '../pages/MobileHome';
import MobileProfile from '../pages/MobileProfile';
import MobileMediaOverviewPage from '../pages/MobileMediaOverviewPage';
import MobileMediaForm from '../components/MobileMediaForm';
import MobileLogin from '../pages/MobileLogin';
import MobileRegister from '../pages/MobileRegister';
import MobileCatalog from '../pages/MobileCatalog';
import MobileRating from '../pages/MobileRating';
import MobileSearch from '../pages/MobileSearch';
const PrivateRoute = ({ children }) => {
const { user } = useAuth();
return user ? children : <Navigate to="/login" />;
};
const MobileRoutes = () => {
return (
<Routes>
<Route path="/" element={<MobileHome />} />
<Route path="/login" element={<MobileLogin />} />
<Route path="/register" element={<MobileRegister />} />
<Route path="/catalog" element={<MobileCatalog />} />
<Route path="/rating" element={<MobileRating />} />
<Route path="/search" element={<MobileSearch />} />
<Route path="/profile/:login" element={<MobileProfile />} />
<Route path="/media/:path" element={<MobileMediaOverviewPage />} />
<Route
path="/media/:path/edit"
element={
<PrivateRoute>
<MobileMediaForm />
</PrivateRoute>
}
/>
<Route
path="/media/new"
element={
<PrivateRoute>
<MobileMediaForm />
</PrivateRoute>
}
/>
</Routes>
);
};
export default MobileRoutes;

17
src/pages/AdminPage.jsx Normal file
View File

@ -0,0 +1,17 @@
import NotificationManager from '../components/admin/NotificationManager';
const tabs = [
{ id: 'dashboard', label: 'Обзор', icon: <FaTachometerAlt /> },
{ id: 'users', label: 'Пользователи', icon: <FaUsers /> },
{ id: 'media', label: 'Медиа', icon: <FaFilm /> },
{ id: 'reviews', label: 'Рецензии', icon: <FaStar /> },
{ id: 'achievements', label: 'Достижения', icon: <FaTrophy /> },
{ id: 'notifications', label: 'Уведомления', icon: <FaBell /> },
{ id: 'settings', label: 'Настройки', icon: <FaCog /> }
];
{activeTab === 'notifications' && (
<div className="space-y-6">
<NotificationManager />
</div>
)}

8
src/pages/HomePage.css Normal file
View File

@ -0,0 +1,8 @@
.scroll-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scroll-container::-webkit-scrollbar {
display: none;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { getLatestReviews, listMedia, listUsersRankedByReviews, getFileUrl, getFeaturedMedia } from '../services/pocketbaseService';
import { useAuth } from '../contexts/AuthContext';
@ -15,6 +15,9 @@ 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';
import MediaCard from '../components/media/MediaCard';
import { useMedia } from '../contexts/MediaContext';
import './HomePage.css';
const HomePage = () => {
const { userProfile } = useAuth();
@ -30,6 +33,9 @@ const HomePage = () => {
const [posters, setPosters] = useState([]);
const [featuredMedia, setFeaturedMedia] = useState([]);
const [isDataReady, setIsDataReady] = useState(false);
const popularContainerRef = useRef(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const containerVariants = {
hidden: { opacity: 0 },
@ -70,6 +76,81 @@ const HomePage = () => {
}
};
const checkScroll = useCallback(() => {
if (!popularContainerRef.current) return;
const container = popularContainerRef.current;
const { scrollLeft, scrollWidth, clientWidth } = container;
// Добавляем отладочную информацию
console.log('Scroll check:', {
scrollLeft,
scrollWidth,
clientWidth,
canScrollLeft: scrollLeft > 0,
canScrollRight: scrollLeft < scrollWidth - clientWidth - 1
});
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
}, []);
useEffect(() => {
const container = popularContainerRef.current;
if (!container) return;
// Принудительно проверяем состояние после монтирования
setTimeout(() => {
checkScroll();
}, 100);
container.addEventListener('scroll', checkScroll);
window.addEventListener('resize', checkScroll);
return () => {
container.removeEventListener('scroll', checkScroll);
window.removeEventListener('resize', checkScroll);
};
}, [checkScroll, popularMedia]);
const scroll = (direction) => {
if (!popularContainerRef.current) return;
const container = popularContainerRef.current;
const scrollAmount = container.clientWidth * 0.8;
// Если достигли конца вправо, переходим в начало
if (direction === 'right' && container.scrollLeft >= container.scrollWidth - container.clientWidth - 10) {
container.scrollTo({
left: 0,
behavior: 'smooth'
});
return;
}
// Если достигли начала влево, переходим в конец
if (direction === 'left' && container.scrollLeft <= 10) {
container.scrollTo({
left: container.scrollWidth,
behavior: 'smooth'
});
return;
}
container.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
};
// Добавляем эффект для принудительной проверки при изменении данных
useEffect(() => {
if (popularMedia.length > 0) {
// Даем время на рендеринг и загрузку изображений
setTimeout(checkScroll, 500);
}
}, [popularMedia, checkScroll]);
const fetchData = useCallback(async () => {
try {
setLoading(true);
@ -90,40 +171,25 @@ const HomePage = () => {
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 || []);
if (popularMediaData?.data) setPopularMedia(popularMediaData.data);
if (latestAddedMediaData?.data) setLatestAddedMedia(latestAddedMediaData.data);
if (latestReviewsData) setLatestReviews(latestReviewsData);
if (topUsersData) 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;
});
if (latestAddedMediaData?.data) {
const posterUrls = latestAddedMediaData.data
.filter(item => item.poster)
.slice(0, 30)
.map(item => getFileUrl(item, 'poster'));
console.log('[HomePage] Итоговый список постеров:', posterUrls);
setPosters(posterUrls);
setPosters(posterUrls);
}
await new Promise(resolve => setTimeout(resolve, 1000));
setIsDataReady(true);
@ -140,21 +206,11 @@ const HomePage = () => {
fetchData();
}, [fetchData]);
useEffect(() => {
const handleClick = (e) => {
console.log('[ClickSpark] Добавление искры:', e.clientX, e.clientY);
addSpark(e.clientX, e.clientY);
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [addSpark]);
useEffect(() => {
const fetchFeaturedMedia = async () => {
try {
const media = await getFeaturedMedia();
setFeaturedMedia(media);
if (media) setFeaturedMedia(media);
} catch (error) {
console.error('Error fetching featured media:', error);
}
@ -211,7 +267,7 @@ const HomePage = () => {
<div className="space-y-4">
{topUsers.map((user) => (
<div key={user.id} className="flex items-center space-x-4 bg-campfire-dark/50 p-4 rounded-lg">
<Link to={`/profile/${user.username}`} className="flex-shrink-0">
<Link to={`/profile/${user.login}`} className="flex-shrink-0">
<img
src={getFileUrl(user, 'profile_picture', { thumb: '100x100' }) || '/default-avatar.png'}
alt={user.username}
@ -219,7 +275,7 @@ const HomePage = () => {
/>
</Link>
<div className="flex-grow">
<Link to={`/profile/${user.username}`} className="text-campfire-light hover:text-campfire-gold">
<Link to={`/profile/${user.login}`} 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>
@ -230,7 +286,103 @@ const HomePage = () => {
);
};
console.log(posters)
const PopularSection = ({ popularMedia }) => {
const containerRef = useRef(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = containerRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
checkScroll();
container.addEventListener('scroll', checkScroll);
window.addEventListener('resize', checkScroll);
return () => {
container.removeEventListener('scroll', checkScroll);
window.removeEventListener('resize', checkScroll);
};
}, [checkScroll, popularMedia]);
const scroll = (direction) => {
if (!containerRef.current) return;
const container = containerRef.current;
const scrollAmount = container.clientWidth * 0.8; // Прокручиваем на 80% ширины контейнера
container.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
};
return (
<section className="py-8">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center space-x-4">
<div className="w-1 h-8 bg-campfire-amber/20 rounded-full"></div>
<h2 className="text-2xl font-bold text-campfire-light">Популярное</h2>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => scroll('left')}
className="w-8 h-8 rounded-full bg-campfire-charcoal/20 border border-campfire-ash/10 flex items-center justify-center text-campfire-light transition-all duration-300 hover:bg-campfire-amber/10 hover:border-campfire-amber/20"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => scroll('right')}
className="w-8 h-8 rounded-full bg-campfire-charcoal/20 border border-campfire-ash/10 flex items-center justify-center text-campfire-light transition-all duration-300 hover:bg-campfire-amber/10 hover:border-campfire-amber/20"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<div className="relative group">
{/* Градиентные затемнения по бокам */}
<div className="absolute left-0 top-0 bottom-0 w-16 bg-gradient-to-r from-campfire-dark to-transparent z-10 pointer-events-none"></div>
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-campfire-dark to-transparent z-10 pointer-events-none"></div>
<div
ref={containerRef}
className="flex overflow-x-auto pb-4 gap-4 scrollbar-none"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{popularMedia.map(media => (
<div key={media.id} className="flex-none w-64 transform transition-transform duration-300 hover:scale-105">
<MediaCard media={media} />
</div>
))}
</div>
</div>
</section>
);
};
// Добавляем стили для скрытия скроллбара
const styles = `
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
`;
return (
<AnimatePresence mode="wait">
@ -263,7 +415,7 @@ console.log(posters)
animate="visible"
variants={containerVariants}
>
<div className="">
<div>
{/* Grid Section with Stats Overlay */}
<motion.div variants={itemVariants}>
<GridSection posters={posters} stats={stats} />
@ -282,39 +434,63 @@ console.log(posters)
<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>
<div className="flex justify-between items-center mb-6">
<div className="flex items-center space-x-4">
<div className="w-1 h-8 bg-campfire-amber/20 rounded-full"></div>
<h2 className="text-2xl font-bold text-campfire-light">Популярное</h2>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => scroll('left')}
className="w-8 h-8 rounded-full bg-campfire-charcoal/20 border border-campfire-ash/10 flex items-center justify-center text-campfire-light transition-all duration-300 hover:bg-campfire-amber/10 hover:border-campfire-amber/20"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => scroll('right')}
className="w-8 h-8 rounded-full bg-campfire-charcoal/20 border border-campfire-ash/10 flex items-center justify-center text-campfire-light transition-all duration-300 hover:bg-campfire-amber/10 hover:border-campfire-amber/20"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{popularMedia.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-8 md:gap-20">
{popularMedia.map((mediaItem) => (
<motion.div
key={mediaItem.id}
variants={itemVariants}
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
>
<Link
to={`/media/${mediaItem.path}`}
className="block group"
>
<TiltedCard
imageSrc={mediaItem.poster ? getFileUrl(mediaItem, 'poster', { thumb: '300x450' }) : 'https://via.placeholder.com/300x450'}
altText={mediaItem.title}
captionText={mediaItem.title}
containerHeight="360px"
containerWidth="100%"
imageHeight="360px"
imageWidth="240px"
scaleOnHover={1.05}
rotateAmplitude={10}
showMobileWarning={false}
showTooltip={false}
displayOverlayContent={false}
rating={mediaItem.average_rating}
releaseDate={mediaItem.release_date}
/>
</Link>
</motion.div>
))}
<div className="relative">
{/* Градиенты по бокам */}
<div className="absolute left-0 top-0 bottom-0 w-24 bg-gradient-to-r from-campfire-dark to-transparent z-10 pointer-events-none"></div>
<div className="absolute right-0 top-0 bottom-0 w-24 bg-gradient-to-l from-campfire-dark to-transparent z-10 pointer-events-none"></div>
<div
ref={popularContainerRef}
className="overflow-x-auto pb-8 custom-scrollbar scroll-smooth px-6"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}}
>
<div className="flex space-x-8 min-w-max">
{/* Дублируем карточки для бесконечной прокрутки */}
{[...popularMedia].map((mediaItem, index) => (
<motion.div
key={`${mediaItem.id}-${index}`}
variants={itemVariants}
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
className="flex-none w-80 transform-gpu"
>
<MediaCard
media={mediaItem}
userProfile={userProfile}
isSmallCard={true}
/>
</motion.div>
))}
</div>
</div>
</div>
) : (
<p className="text-campfire-ash text-center py-8">Нет данных о популярном медиа.</p>
@ -346,7 +522,7 @@ console.log(posters)
<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">
<Link to={`/profile/${topUsers[1].login}`} className="text-campfire-light hover:text-campfire-amber font-medium">
{topUsers[1].username}
</Link>
<span className="text-campfire-ash text-sm">{topUsers[1].review_count} рецензий</span>
@ -371,7 +547,7 @@ console.log(posters)
<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">
<Link to={`/profile/${topUsers[0].login}`} 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>
@ -396,7 +572,7 @@ console.log(posters)
<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">
<Link to={`/profile/${topUsers[2].login}`} 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>
@ -439,7 +615,7 @@ console.log(posters)
scaleOnHover={1.05}
rotateAmplitude={10}
showMobileWarning={false}
showTooltip={false}
showTooltip={true}
displayOverlayContent={false}
rating={mediaItem.average_rating}
releaseDate={mediaItem.release_date}
@ -464,4 +640,4 @@ console.log(posters)
);
};
export default HomePage;
export default HomePage;

View File

@ -1,33 +1,25 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; // Import useAuth
import { useAuth } from '../contexts/AuthContext';
import { getUsers } from '../services/pocketbaseService';
import { FaTelegram } from 'react-icons/fa';
const LoginPage = () => {
const [login, setLogin] = useState('');
const [loginInput, setLoginInput] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { user, userProfile, loading: authLoading, signIn, isInitialized } = useAuth(); // Get isInitialized
console.log('LoginPage: Rendering. State:', { user: !!user, userProfile: !!userProfile, authLoading, isInitialized, componentLoading: loading, componentError: error }); // Add component render log
const { user, userProfile, loading: authLoading, login, isInitialized } = useAuth();
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...');
if (!authLoading && isInitialized) {
if (user && userProfile) {
navigate(`/profile/${userProfile.username}`);
}
}
}, [user, userProfile, authLoading, isInitialized, navigate]); // Add isInitialized to dependencies
}, [user, userProfile, authLoading, isInitialized, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
@ -35,31 +27,38 @@ const LoginPage = () => {
setLoading(true);
try {
await signIn(login, password);
navigate('/');
await login(loginInput, password);
// После успешного входа перенаправляем на страницу профиля
if (userProfile) {
navigate(`/profile/${userProfile.username}`);
}
} catch (err) {
console.error('Login error:', err);
setError(err.message);
let message = '';
if (err?.message?.includes('Неверный логин или пароль') || err?.message?.includes('Failed to authenticate') || err?.message?.includes('Invalid login credentials')) {
// Попробуем предложить похожий логин
try {
const users = await getUsers(1, 50);
if (users && users.length > 0) {
const randomUser = users[Math.floor(Math.random() * users.length)];
message = `Неверный логин или пароль. Возможно, вы имели в виду: ${randomUser.username}`;
} else {
message = 'Неверный логин или пароль.';
}
} catch {
message = 'Неверный логин или пароль.';
}
} else if (err?.message?.toLowerCase().includes('user') && err?.message?.toLowerCase().includes('not found')) {
message = 'Пользователь не найден.';
} else {
message = err.message || 'Произошла ошибка при входе.';
}
setError(message);
} 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 (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="w-full max-w-md p-8 bg-campfire-charcoal rounded-lg shadow-lg">
@ -77,13 +76,16 @@ const LoginPage = () => {
<input
type="text"
id="login"
value={login}
onChange={(e) => setLogin(e.target.value)}
value={loginInput}
onChange={(e) => setLoginInput(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">
Логин чувствителен к регистру букв
</p>
</div>
<div>
@ -135,6 +137,25 @@ const LoginPage = () => {
"Войти"
)}
</button>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-campfire-ash/30"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-campfire-charcoal text-campfire-ash">или</span>
</div>
</div>
{/* Временно отключена авторизация через Telegram
<Link
to="/auth/telegram"
className="flex items-center justify-center w-full px-4 py-2 text-white bg-[#0088cc] hover:bg-[#0077b3] rounded-md transition-colors duration-200"
>
<FaTelegram className="w-5 h-5 mr-2" />
Войти через Telegram
</Link>
*/}
</form>
<div className="mt-6 text-center text-sm text-campfire-ash">

View File

@ -19,6 +19,7 @@ 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
import { toast } from 'react-hot-toast';
const MediaOverviewPage = () => { // Renamed component
// Get the 'path' parameter from the URL
@ -252,49 +253,31 @@ const MediaOverviewPage = () => { // Renamed component
};
// 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
const handleMediaEditSubmit = async (formData) => {
try {
setLoading(true);
const response = await updateMedia(media.id, formData);
setMedia(response);
setIsEditModalOpen(false);
toast.success('Контент успешно обновлен');
} catch (error) {
console.error('Error updating media:', error);
let errorMessage = 'Произошла ошибка при обновлении контента';
if (error.response?.data) {
const data = error.response.data;
if (typeof data === 'object') {
const errors = Object.entries(data)
.map(([field, error]) => `${field}: ${error.message || error}`)
.join(', ');
errorMessage = errors || errorMessage;
}
}
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
@ -363,18 +346,24 @@ const MediaOverviewPage = () => { // Renamed component
return (
<div className="min-h-screen bg-campfire-dark pt-0"> {/* Changed pt-20 to pt-0 */}
<div className="min-h-screen bg-campfire-dark"> {/* Добавляем отступ сверху */}
{/* Hero Section */}
<div
className="relative h-96 bg-cover bg-center"
// Use getFileUrl for backdrop field
style={{ backgroundImage: `url(${getFileUrl(media, 'backdrop')})` }}
className="relative h-[666px] w-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${getFileUrl(media, 'backdrop')})`,
backgroundSize: 'cover',
backgroundPosition: 'top', // Смещаем изображение немного вниз
backgroundRepeat: 'no-repeat'
}}
>
<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">
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark via-campfire-dark/80 to-transparent"></div>
{/* Content */}
<div className="container-custom relative z-10 h-full flex items-end 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"
@ -383,29 +372,20 @@ const MediaOverviewPage = () => { // Renamed component
<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>
)}
<span className="text-campfire-amber mr-4 text-2xl font-bold">
{media.average_rating ? parseFloat(media.average_rating).toFixed(1) : 'N/A'} / 10
</span>
<FaFire className="text-campfire-amber mr-4 text-xl" />
<span className="text-campfire-ash text-lg mr-4">
{media.review_count || 0} рецензий
</span>
{media.release_date && (
<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>
@ -549,7 +529,7 @@ const MediaOverviewPage = () => { // Renamed component
) : (
<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>, чтобы оставить рецензию.
<a href="/auth/login" className="text-campfire-amber hover:underline">Войдите</a>, чтобы оставить рецензию.
</p>
</div>
)}

View File

@ -1,40 +1,124 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { FaHome } from 'react-icons/fa';
import FuzzyText from '../components/reactbits/TextAnimations/FuzzyText/FuzzyText';
import notFoundImage from '../assets/404.webp';
import catImage from '../assets/cat.png';
const NotFoundPage = () => {
const [isVisible, setIsVisible] = useState(false);
const [isScaled, setIsScaled] = useState(false);
const [showContent, setShowContent] = useState(false);
useEffect(() => {
setIsVisible(true);
const timer1 = setTimeout(() => {
setShowContent(true);
}, 800);
const timer2 = setTimeout(() => {
setIsScaled(true);
}, 1500);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, []);
return (
<div className="min-h-screen bg-campfire-dark flex flex-col items-center justify-center p-4">
<div className="text-center">
<img
src={notFoundImage}
alt="404"
className="w-64 h-64 mb-8 mx-auto"
/>
<FuzzyText
fontSize="clamp(4rem, 15vw, 12rem)"
fontWeight={900}
color="#FFA500"
baseIntensity={0.2}
hoverIntensity={0.6}
>
404
</FuzzyText>
<p className="text-campfire-light text-xl mt-4 mb-8">
Страница не найдена
</p>
<Link
to="/"
className="inline-flex items-center px-6 py-3 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/90 transition-colors duration-200"
>
<FaHome className="mr-2" />
окак
</Link>
<div className={`text-center transition-opacity duration-1000 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
<div className="relative min-h-[60vh] flex flex-col items-center justify-center">
{/* Верхний контент (картинка) */}
<div className={`absolute transition-all duration-1000 transform ${
showContent ? 'opacity-100 -translate-y-48' : 'opacity-0 translate-y-0'
}`}>
<div className="relative p-8">
<img
src={catImage}
alt="окак"
className={`w-full max-w-md mx-auto transition-all duration-500 ${
isScaled ? 'scale-110' : 'scale-100'
} hover:scale-150 hover:translate-y-14`}
style={{
transition: 'all 0.5s ease-in-out',
maskImage: `
radial-gradient(ellipse at center, black 40%, transparent 50%),
linear-gradient(to bottom, black 90%, transparent 100%)
`,
WebkitMaskImage: `
radial-gradient(ellipse at center, black 40%, transparent 50%),
linear-gradient(to bottom, black 90%, transparent 100%)
`
}}
/>
{/* Дополнительная тень */}
<div
className="absolute inset-0 -z-10"
style={{
filter: 'blur(20px)',
transform: 'scale(1.2)'
}}
/>
</div>
</div>
{/* Центральный текст 404 */}
<div className="relative z-10">
<FuzzyText
fontSize="clamp(4rem, 15vw, 12rem)"
fontWeight={900}
color="#FFA500"
baseIntensity={0.2}
hoverIntensity={0.6}
>
404
</FuzzyText>
</div>
{/* Нижний контент (текст и кнопка) */}
<div className={`absolute transition-all duration-1000 transform ${
showContent ? 'opacity-100 translate-y-40' : 'opacity-0 translate-y-0'
}`}>
<p className="text-campfire-light text-xl mt-4 mb-8">
Страница не найдена
</p>
<Link
to="/"
className="group relative inline-flex items-center px-8 py-4 bg-campfire-amber text-campfire-dark rounded-lg transition-all duration-300 transform hover:scale-105 hover:shadow-2xl overflow-hidden"
style={{
background: 'linear-gradient(135deg, #FF3300 0%, #FF6a00 100%)',
boxShadow: '0 4px 15px rgba(255, 165, 0, 0.2)'
}}
>
{/* Animated shine effect */}
<div
className="absolute inset-0 -top-full group-hover:top-full transform transition-all duration-700"
style={{
background: 'linear-gradient(to bottom, rgba(255, 255, 255, 0.2) 0%, transparent 100%)'
}}
/>
{/* Icon with enhanced animation */}
<FaHome className="mr-3 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 relative z-10" />
<span className="relative z-10 font-semibold">Окак, вернуться домой</span>
{/* Button glow effect */}
<div
className="absolute inset-0 -z-10 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300"
style={{
background: 'radial-gradient(circle at center, rgba(255, 165, 0, 0.4) 0%, transparent 70%)',
filter: 'blur(10px)',
transform: 'scale(1.2)'
}}
/>
</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

@ -87,7 +87,7 @@ const RatingPage = () => {
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">
<Link to={`/profile/${user.login}`} 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'}

View File

@ -10,28 +10,16 @@ function RegisterPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { user, userProfile, loading: authLoading, signUp, isInitialized } = useAuth();
const { user, userProfile, loading: authLoading, register, isInitialized } = useAuth();
const navigate = useNavigate();
console.log('RegisterPage: Rendering. State:', { user: !!user, userProfile: !!userProfile, authLoading, isInitialized, componentLoading: loading, componentError: error }); // Add component render log
useEffect(() => {
console.log('RegisterPage useEffect: Running. State:', { user: !!user, userProfile: !!userProfile, authLoading, isInitialized }); // Add useEffect log
if (!authLoading && isInitialized) { // Wait for auth to be initialized and not loading
if (user && userProfile) {
console.log('RegisterPage useEffect: User and profile loaded, redirecting to home.');
navigate("/"); // Redirect after successful registration or if already logged in
} else {
console.log('RegisterPage useEffect: No user and auth initialized, staying on register.');
}
} else if (authLoading) {
console.log('RegisterPage useEffect: Auth is loading...');
} else if (!isInitialized) {
console.log('RegisterPage useEffect: Auth is not initialized...');
if (!authLoading && isInitialized) {
if (user && userProfile) {
navigate("/");
}
}
}, [user, userProfile, authLoading, isInitialized, navigate]); // Add isInitialized to dependencies
}, [user, userProfile, authLoading, isInitialized, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
@ -51,13 +39,11 @@ function RegisterPage() {
return;
}
// Проверка на допустимые символы в логине
if (!/^[a-zA-Z0-9_]+$/.test(login)) {
setError("Логин может содержать только латинские буквы, цифры и знак подчеркивания");
return;
}
// Проверка длины логина
if (login.length < 3 || login.length > 20) {
setError("Логин должен быть от 3 до 20 символов");
return;
@ -66,14 +52,8 @@ function RegisterPage() {
try {
setError("");
setLoading(true);
console.log('RegisterPage handleSubmit: Attempting sign up...');
await signUp(login, password, email || null);
console.log('RegisterPage handleSubmit: Sign up process initiated. Redirect handled by useEffect.');
// Redirect is now handled by the useEffect based on auth state change
await register(login, password, email || null);
} catch (err) {
console.error("Registration error:", err);
// More specific error handling for PocketBase unique constraints
if (err.response && err.response.data) {
const errorData = err.response.data;
if (errorData.login && errorData.login.code === 'validation_not_unique') {
@ -91,20 +71,13 @@ function RegisterPage() {
}
};
// 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
if (authLoading || !isInitialized) {
return (
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
return (
<div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark text-campfire-light">
@ -144,6 +117,9 @@ function RegisterPage() {
<p className="text-xs text-campfire-ash mt-1">
От 3 до 20 символов, только латинские буквы, цифры и _
</p>
<p className="text-xs text-campfire-ash mt-1">
Логин чувствителен к регистру букв
</p>
</div>
<div>

View File

@ -2,7 +2,13 @@ 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
import { FaEdit, FaTrash } from 'react-icons/fa'; // Icons
import * as FaIcons from 'react-icons/fa';
import * as MdIcons from 'react-icons/md';
import * as IoIcons from 'react-icons/io5';
import * as BiIcons from 'react-icons/bi';
import * as HiIcons from 'react-icons/hi';
import * as RiIcons from 'react-icons/ri';
const AdminAchievementsPage = () => {
const [achievements, setAchievements] = useState([]);
@ -11,6 +17,20 @@ const AdminAchievementsPage = () => {
const [showFormModal, setShowFormModal] = useState(false); // State for form modal
const [achievementToEdit, setAchievementToEdit] = useState(null); // State for editing
// Получаем компонент иконки по имени
const getIconComponent = (iconName) => {
const prefix = iconName.substring(0, 2).toLowerCase();
switch (prefix) {
case 'fa': return FaIcons[iconName];
case 'md': return MdIcons[iconName];
case 'io': return IoIcons[iconName];
case 'bi': return BiIcons[iconName];
case 'hi': return HiIcons[iconName];
case 'ri': return RiIcons[iconName];
default: return FaIcons.FaTrophy;
}
};
useEffect(() => {
loadAchievements();
}, []);
@ -90,37 +110,38 @@ const AdminAchievementsPage = () => {
</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
{achievements.map((achievement) => {
const IconComponent = getIconComponent(achievement.icon);
return (
<div
key={achievement.id}
className="bg-campfire-charcoal rounded-lg p-4 flex items-center space-x-4 border border-campfire-ash/20"
>
<div className="flex-shrink-0 text-campfire-amber">
<IconComponent 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
</button>
<button
onClick={() => handleDeleteClick(achievement.id)}
className="text-red-500 hover:text-red-400"
>
>
<FaTrash size={18} />
</button>
</button>
</div>
</div>
</div>
))}
);
})}
</div>
)}

View File

@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react';
import { getAdminStats } from '../../services/pocketbaseService';
import { FaUsers, FaFilm, FaComment, FaHeadset, FaLightbulb } from 'react-icons/fa';
import { FaUsers, FaFilm, FaComment, FaHeadset, FaLightbulb, FaBell, FaTachometerAlt } from 'react-icons/fa';
import NotificationManager from '../../components/admin/NotificationManager';
const AdminDashboard = () => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState('dashboard');
useEffect(() => {
const loadStats = async () => {
@ -47,68 +49,98 @@ const AdminDashboard = () => {
<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>
{/* Tabs */}
<div className="flex space-x-4 mb-6">
<button
onClick={() => setActiveTab('dashboard')}
className={`px-4 py-2 rounded-md flex items-center space-x-2 ${
activeTab === 'dashboard' ? 'bg-campfire-amber text-campfire-dark' : 'bg-campfire-charcoal text-campfire-light'
}`}
>
<FaTachometerAlt />
<span>Обзор</span>
</button>
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 rounded-md flex items-center space-x-2 ${
activeTab === 'notifications' ? 'bg-campfire-amber text-campfire-dark' : 'bg-campfire-charcoal text-campfire-light'
}`}
>
<FaBell />
<span>Уведомления</span>
</button>
</div>
{activeTab === 'dashboard' && (
<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>
)}
{activeTab === 'notifications' && (
<div className="space-y-6">
<NotificationManager />
</div>
)}
</div>
);
};

View File

@ -4,7 +4,10 @@ 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
import TMDBImportModal from '../../components/admin/TMDBImportModal';
import AIReviewModal from '../../components/admin/AIReviewModal';
import AICreateModal from '../../components/admin/AICreateModal';
import { FaEdit, FaTrashAlt, FaPlus, FaTv, FaDownload, FaRobot } from 'react-icons/fa'; // Import FaTv icon
const AdminMediaPage = () => {
const navigate = useNavigate();
@ -16,10 +19,13 @@ const AdminMediaPage = () => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isAICreateModalOpen, setIsAICreateModalOpen] = 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')
const [isAIReviewModalOpen, setIsAIReviewModalOpen] = useState(false);
const [selectedMedia, setSelectedMedia] = useState(null);
// Check if the current user is admin
const isAdmin = userProfile?.role === 'admin';
@ -115,6 +121,26 @@ const AdminMediaPage = () => {
navigate(`/admin/media/${mediaId}/seasons`);
};
const handleImportSuccess = (importData) => {
setMediaToEdit(importData);
setIsAddModalOpen(true);
};
const handleAICreateSuccess = (importData) => {
setMediaToEdit(importData);
setIsAddModalOpen(true);
};
const handleAIReview = (media) => {
setSelectedMedia(media);
setIsAIReviewModalOpen(true);
};
const handleAIReviewSuccess = () => {
setIsAIReviewModalOpen(false);
setSelectedMedia(null);
loadMediaData(); // Перезагружаем данные после создания рецензии
};
if (authLoading || loading) {
return <div className="flex justify-center items-center h-screen text-campfire-light">Загрузка...</div>;
@ -137,16 +163,37 @@ const AdminMediaPage = () => {
return (
<div> {/* Removed container-custom and pt-20 */}
<div className="container-custom py-12">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-bold text-campfire-light">Управление контентом</h2>
<h1 className="text-3xl font-bold text-campfire-light">
Управление <span className="font-semibold text-campfire-amber">контентом</span>
</h1>
<div className="flex gap-4">
<button
onClick={() => setIsImportModalOpen(true)}
className="btn-secondary flex items-center gap-2"
>
<FaDownload />
<span>Импорт из TMDB</span>
</button>
<button
onClick={() => setIsAICreateModalOpen(true)}
className="btn-secondary flex items-center gap-2"
>
<FaRobot />
<span>Создать с помощью CampFireAI</span>
</button>
<button
onClick={handleAddMedia}
className="btn-primary flex items-center space-x-2"
onClick={() => {
setMediaToEdit(null);
setIsAddModalOpen(true);
}}
className="btn-primary flex items-center gap-2"
>
<FaPlus size={18} />
<FaPlus />
<span>Добавить контент</span>
</button>
</div>
</div>
{/* Filters */}
@ -273,6 +320,14 @@ const AdminMediaPage = () => {
>
<FaTrashAlt size={18} />
</button>
{/* AI Review Button */}
<button
onClick={() => handleAIReview(media)}
className="text-campfire-amber hover:text-campfire-light transition-colors"
title="Создать ИИ-рецензию"
>
<FaRobot size={18} />
</button>
</div>
</td>
</tr>
@ -286,10 +341,10 @@ const AdminMediaPage = () => {
<Modal
isOpen={isAddModalOpen}
onClose={handleFormCancel}
title="Добавить новый контент"
title={mediaToEdit && mediaToEdit.id ? "Редактировать контент" : "Добавить новый контент"}
size="lg" // Use lg size
>
<MediaForm onSuccess={handleFormSuccess} />
<MediaForm onSuccess={handleFormSuccess} media={mediaToEdit} />
</Modal>
{/* Edit Media Modal */}
@ -302,6 +357,48 @@ const AdminMediaPage = () => {
{/* Pass mediaToEdit to the form */}
<MediaForm media={mediaToEdit} onSuccess={handleFormSuccess} />
</Modal>
{/* Import Modal */}
<Modal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
title="Импорт из TMDB"
size="lg"
>
<TMDBImportModal
onClose={() => setIsImportModalOpen(false)}
onImport={handleImportSuccess}
/>
</Modal>
{/* AI Review Modal */}
<Modal
isOpen={isAIReviewModalOpen}
onClose={() => setIsAIReviewModalOpen(false)}
title="ИИ-рецензия"
size="lg"
>
{selectedMedia && (
<AIReviewModal
media={selectedMedia}
onClose={() => setIsAIReviewModalOpen(false)}
onSuccess={handleAIReviewSuccess}
/>
)}
</Modal>
{/* AI Create Modal */}
<Modal
isOpen={isAICreateModalOpen}
onClose={() => setIsAICreateModalOpen(false)}
title="Создать с помощью CampFireAI"
size="lg"
>
<AICreateModal
onClose={() => setIsAICreateModalOpen(false)}
onImport={handleAICreateSuccess}
/>
</Modal>
</div>
);
};

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;

122
src/routes/index.jsx Normal file
View File

@ -0,0 +1,122 @@
import React, { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import AuthRoute from '../components/auth/AuthRoute';
import GuestRoute from '../components/auth/GuestRoute';
import AdminRoute from '../components/auth/AdminRoute';
import AdminLayout from '../components/admin/AdminLayout';
import LoadingSpinner from '../components/common/LoadingSpinner';
import TelegramAuth from '../components/TelegramAuth';
// Ленивая загрузка страниц
const HomePage = lazy(() => import('../pages/HomePage'));
const CatalogPage = lazy(() => import('../pages/CatalogPage'));
const MediaOverviewPage = lazy(() => import('../pages/MediaOverviewPage'));
const ProfilePage = lazy(() => import('../pages/ProfilePage'));
const RatingPage = lazy(() => import('../pages/RatingPage'));
const SupportPage = lazy(() => import('../pages/SupportPage'));
const NotFoundPage = lazy(() => import('../pages/NotFoundPage'));
const LoginPage = lazy(() => import('../pages/LoginPage'));
const RegisterPage = lazy(() => import('../pages/RegisterPage'));
// Админские страницы
const AdminDashboard = lazy(() => import('../pages/admin/AdminDashboard'));
const AdminMediaPage = lazy(() => import('../pages/admin/AdminMediaPage'));
const AdminUsersPage = lazy(() => import('../pages/admin/AdminUsersPage'));
const AdminSeasonsPage = lazy(() => import('../pages/admin/AdminSeasonsPage'));
const AdminAchievementsPage = lazy(() => import('../pages/admin/AdminAchievementsPage'));
const AdminSupportPage = lazy(() => import('../pages/admin/AdminSupportPage'));
const AdminSuggestionsPage = lazy(() => import('../pages/admin/AdminSuggestionsPage'));
// Легальные страницы
const PrivacyPolicyPage = lazy(() => import('../pages/legal/PrivacyPolicyPage'));
const TermsOfServicePage = lazy(() => import('../pages/legal/TermsOfServicePage'));
const UserAgreementPage = lazy(() => import('../pages/legal/UserAgreementPage'));
// Компонент для обработки ошибок
const ErrorBoundary = ({ children }) => {
const [hasError, setHasError] = React.useState(false);
const [error, setError] = React.useState(null);
React.useEffect(() => {
const handleError = (error) => {
setHasError(true);
setError(error);
console.error('Route Error:', error);
};
window.addEventListener('error', handleError);
return () => window.removeEventListener('error', handleError);
}, []);
if (hasError) {
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">
<h2 className="text-xl font-bold mb-2">Произошла ошибка</h2>
<p>{error?.message || 'Неизвестная ошибка'}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-campfire-amber text-campfire-dark rounded-lg hover:bg-campfire-amber/80 transition-colors"
>
Перезагрузить страницу
</button>
</div>
</div>
);
}
return children;
};
// Компонент для обертки ленивой загрузки
const LazyLoad = ({ children }) => (
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
{children}
</Suspense>
</ErrorBoundary>
);
const AppRoutes = () => {
return (
<Routes>
<Route path="/" element={<LazyLoad><HomePage /></LazyLoad>} />
<Route path="/catalog" element={<LazyLoad><CatalogPage /></LazyLoad>} />
<Route path="/media/:path" element={<LazyLoad><MediaOverviewPage /></LazyLoad>} />
<Route path="/rating" element={<LazyLoad><RatingPage /></LazyLoad>} />
{/* Страницы авторизации */}
<Route path="/auth">
<Route path="login" element={<GuestRoute><LazyLoad><LoginPage /></LazyLoad></GuestRoute>} />
<Route path="register" element={<GuestRoute><LazyLoad><RegisterPage /></LazyLoad></GuestRoute>} />
</Route>
<Route path="/profile" element={<AuthRoute><LazyLoad><ProfilePage /></LazyLoad></AuthRoute>} />
<Route path="/profile/:login" element={<LazyLoad><ProfilePage /></LazyLoad>} />
<Route path="/profile/telegram-link" element={<AuthRoute><TelegramAuth /></AuthRoute>} />
<Route path="/admin" element={<AdminRoute />}>
<Route element={<AdminLayout />}>
<Route index element={<LazyLoad><AdminDashboard /></LazyLoad>} />
<Route path="media" element={<LazyLoad><AdminMediaPage /></LazyLoad>} />
<Route path="media/:mediaId/seasons" element={<LazyLoad><AdminSeasonsPage /></LazyLoad>} />
<Route path="users" element={<LazyLoad><AdminUsersPage /></LazyLoad>} />
<Route path="seasons" element={<LazyLoad><AdminSeasonsPage /></LazyLoad>} />
<Route path="achievements" element={<LazyLoad><AdminAchievementsPage /></LazyLoad>} />
<Route path="support" element={<LazyLoad><AdminSupportPage /></LazyLoad>} />
<Route path="suggestions" element={<LazyLoad><AdminSuggestionsPage /></LazyLoad>} />
</Route>
</Route>
<Route path="/support" element={<AuthRoute><LazyLoad><SupportPage /></LazyLoad></AuthRoute>} />
<Route path="/privacy-policy" element={<LazyLoad><PrivacyPolicyPage /></LazyLoad>} />
<Route path="/terms-of-service" element={<LazyLoad><TermsOfServicePage /></LazyLoad>} />
<Route path="/user-agreement" element={<LazyLoad><UserAgreementPage /></LazyLoad>} />
<Route path="*" element={<LazyLoad><NotFoundPage /></LazyLoad>} />
</Routes>
);
};
export default AppRoutes;

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