8.2 update

This commit is contained in:
Degradin 2025-09-05 16:12:32 +03:00
parent 881e6da923
commit 7ba6562c74
24 changed files with 894 additions and 388 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": {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

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

@ -80,7 +80,7 @@ function MediaForm({ media, onSuccess }) {
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);
@ -99,35 +99,86 @@ function MediaForm({ media, onSuccess }) {
// 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
@ -359,7 +410,7 @@ function MediaForm({ media, onSuccess }) {
if (backdrop instanceof File) {
formData.append('backdrop_src', backdrop);
formData.append('backdrop', backdrop);
}
}
}
// Convert characteristics array to object

View File

@ -62,6 +62,62 @@ const TMDBImportModal = ({ onClose, onImport }) => {
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,
@ -69,12 +125,21 @@ const TMDBImportModal = ({ onClose, onImport }) => {
type: item.media_type === 'movie' ? 'movie' : 'tv',
overview: details.overview,
release_date: details.release_date || details.first_air_date,
poster_path: details.poster_path,
backdrop_path: details.backdrop_path,
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) {

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

@ -5,27 +5,22 @@ import React, {
useEffect,
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 { getNotificationSettings } from '../services/notificationService';
import {
pb,
signIn,
signUp,
signOut,
getCurrentUser
} from '../services/pocketbaseService';
import { useCache } from '../hooks/useCache';
import { useErrorBoundary } from '../hooks/useErrorBoundary';
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;
@ -47,6 +42,7 @@ export const AuthProvider = ({ children }) => {
const profile = await pb.collection('users').getOne(user.id);
return profile;
} catch (err) {
console.error('Error fetching profile:', err);
handleError(err);
return null;
}
@ -59,49 +55,85 @@ export const AuthProvider = ({ children }) => {
// Инициализация аутентификации
useEffect(() => {
let mounted = true;
const initializeAuth = async () => {
if (!mounted) return;
try {
setLoading(true);
clearError();
// Проверяем текущую сессию
const authData = pb.authStore.model;
if (authData) {
setUser(authData);
const profile = await pb.collection('users').getOne(authData.id);
setUserProfile(profile);
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) {
handleError(err);
} catch (error) {
console.error('Ошибка инициализации:', error);
} finally {
setLoading(false);
setIsInitialized(true);
if (mounted) {
setLoading(false);
setIsInitialized(true);
}
}
};
initializeAuth();
}, [clearError, handleError]);
// Подписываемся на изменения состояния аутентификации
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 () => {
mounted = false;
unsubscribe();
};
}, []);
// Обработчик входа
const login = useCallback(async (email, password) => {
const handleLogin = useCallback(async (username, password) => {
try {
clearError();
const authData = await pb.collection('users').authWithPassword(email, password);
setUser(authData.record);
const profile = await pb.collection('users').getOne(authData.record.id);
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 logout = useCallback(() => {
const handleLogout = useCallback(() => {
try {
clearError();
pb.authStore.clear();
signOut();
setUser(null);
setUserProfile(null);
} catch (err) {
@ -109,26 +141,11 @@ export const AuthProvider = ({ children }) => {
}
}, [clearError, handleError]);
// Обновление профиля
const updateProfile = useCallback(async (data) => {
try {
clearError();
if (!user) throw new Error('Пользователь не авторизован');
const updatedProfile = await pb.collection('users').update(user.id, data);
setUserProfile(updatedProfile);
return updatedProfile;
} catch (err) {
handleError(err);
throw err;
}
}, [user, clearError, handleError]);
// Обработчик регистрации
const signUp = useCallback(async (login, password, email = null) => {
const handleRegister = useCallback(async (username, password, email = null) => {
try {
clearError();
const result = await pbSignUp(login, password, email);
const result = await signUp(username, password, email);
setUser(result.user);
setUserProfile(result.profile);
return result;
@ -144,10 +161,9 @@ export const AuthProvider = ({ children }) => {
loading,
isInitialized,
error,
login,
logout,
signUp,
updateProfile,
login: handleLogin,
logout: handleLogout,
register: handleRegister,
refetchProfile
};

View File

@ -1,7 +1,7 @@
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, FaComment, FaCrown, FaMedal } from 'react-icons/fa';
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';
@ -173,23 +173,18 @@ const MobileHome = () => {
<span>{review.rating}</span>
</div>
<span className="text-campfire-ash"></span>
<span className="text-campfire-ash text-xs">
{new Date(review.created).toLocaleDateString()}
</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 className="flex items-center gap-4 mt-2">
<div className="flex items-center text-campfire-ash">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<div className="flex items-center text-campfire-ash">
<FaComment className="mr-1" />
<span>{review.comments?.length || 0}</span>
</div>
</div>
</div>
</div>
</Link>

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
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, FaComment, FaShare, FaArrowLeft } from 'react-icons/fa';
import { FaStar, FaHeart, FaRegHeart, FaShare, FaArrowLeft } from 'react-icons/fa';
import MobileReviewForm from '../components/MobileReviewForm';
const MobileMedia = () => {
@ -224,10 +224,6 @@ const MobileMedia = () => {
)}
<span>{review.likes?.length || 0}</span>
</button>
<button className="flex items-center space-x-1 text-campfire-ash hover:text-campfire-amber">
<FaComment />
<span>{review.comments?.length || 0}</span>
</button>
<button
onClick={handleShare}
className="flex items-center space-x-1 text-campfire-ash hover:text-campfire-amber"

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { pb, getFileUrl } from '../../services/pocketbaseService';
import { FaStar, FaHeart, FaComment, FaEdit, FaTrash, FaFire } from 'react-icons/fa';
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';
@ -421,10 +421,6 @@ const MobileMediaOverviewPage = () => {
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<div className="flex items-center">
<FaComment className="mr-1" />
<span>{review.comments?.length || 0}</span>
</div>
<span></span>
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
@ -449,21 +445,11 @@ const MobileMediaOverviewPage = () => {
title={userReview ? 'Редактировать рецензию' : 'Написать рецензию'}
>
<ReviewForm
mediaId={media.id}
seasonId={selectedSeasonId}
mediaType={media.type}
progress_type={media.progress_type}
characteristics={media.characteristics}
existingReview={userReview}
onEdit={async (reviewId, data) => {
try {
await pb.collection('reviews').update(reviewId, data);
await fetchUserReview();
setIsReviewFormOpen(false);
} catch (err) {
console.error('Error updating review:', err);
}
}}
mediaId={media.id}
seasonId={selectedSeasonId}
mediaType={media.type}
progress_type={media.progress_type}
characteristics={media.characteristics}
onSubmit={async (data) => {
try {
if (userReview) {
@ -487,6 +473,21 @@ const MobileMediaOverviewPage = () => {
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);
@ -503,6 +504,7 @@ const MobileMediaOverviewPage = () => {
console.error('Error deleting review:', err);
}
}}
existingReview={userReview}
seasons={seasons}
selectedSeasonId={selectedSeasonId}
/>

View File

@ -2,7 +2,7 @@ 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, FaComment, FaEdit, FaSignOutAlt, FaPalette, FaTrophy, FaGripVertical } from 'react-icons/fa';
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';
@ -294,29 +294,17 @@ const MobileProfile = () => {
<h3 className="text-lg 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 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 className="text-campfire-ash"></span>
<span className="text-campfire-ash text-sm">
{new Date(review.created).toLocaleDateString()}
</span>
<span></span>
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
<p className="text-campfire-light/80 line-clamp-2">
{review.text}
</p>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center text-campfire-ash">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<div className="flex items-center text-campfire-ash">
<FaComment className="mr-1" />
<span>{review.comments?.length || 0}</span>
</div>
</div>
</div>
</div>
</Link>
@ -357,29 +345,17 @@ const MobileProfile = () => {
<h3 className="text-lg 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 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 className="text-campfire-ash"></span>
<span className="text-campfire-ash text-sm">
{new Date(review.created).toLocaleDateString()}
</span>
<span></span>
<span>{new Date(review.created).toLocaleDateString()}</span>
</div>
<p className="text-campfire-light/80 line-clamp-2">
{review.text}
</p>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center text-campfire-ash">
<FaHeart className="mr-1" />
<span>{review.likes?.length || 0}</span>
</div>
<div className="flex items-center text-campfire-ash">
<FaComment className="mr-1" />
<span>{review.comments?.length || 0}</span>
</div>
</div>
</div>
</div>
</Link>

View File

@ -1,9 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { pb } from '../../services/pocketbaseService';
import { getFileUrl } from '../../services/pocketbaseService';
import { pb, getFileUrl } from '../../services/pocketbaseService';
import { useAuth } from '../../contexts/AuthContext';
import { FaArrowLeft, FaStar, FaHeart, FaComment, FaEdit, FaTrash } from 'react-icons/fa';
import { FaArrowLeft, FaStar, FaHeart, FaEdit, FaTrash } from 'react-icons/fa';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';

View File

@ -19,8 +19,13 @@ const LazyLoad = ({ children }) => (
);
const PrivateRoute = ({ children }) => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? children : <Navigate to="/auth/login" />;
const { user, isInitialized } = useAuth();
if (!isInitialized) {
return <LoadingSpinner />;
}
return user ? children : <Navigate to="/auth/login" />;
};
const MobileRoutes = () => {

View File

@ -1,6 +1,6 @@
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';
@ -11,7 +11,7 @@ const LoginPage = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { user, userProfile, loading: authLoading, login, isInitialized } = useAuth(); // Get isInitialized
const { user, userProfile, loading: authLoading, login, isInitialized } = useAuth();
useEffect(() => {
if (!authLoading && isInitialized) {
@ -83,6 +83,9 @@ const LoginPage = () => {
required
autoComplete="username"
/>
<p className="text-xs text-campfire-ash mt-1">
Логин чувствителен к регистру букв
</p>
</div>
<div>

View File

@ -7,41 +7,118 @@ 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 transition-opacity duration-1000 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
<img
src={catImage}
alt="Кот в капюшоне"
className={`w-full max-w-md mb-8 mx-auto transition-all duration-1000 ease-out ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-16'}`} // Увеличено translate-y для большего начального смещения
/>
<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="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;

View File

@ -10,7 +10,7 @@ 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();
useEffect(() => {
@ -52,7 +52,7 @@ function RegisterPage() {
try {
setError("");
setLoading(true);
await signUp(login, password, email || null);
await register(login, password, email || null);
} catch (err) {
if (err.response && err.response.data) {
const errorData = err.response.data;
@ -117,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

@ -6,6 +6,7 @@ import Modal from '../../components/common/Modal';
import MediaForm from '../../components/admin/MediaForm';
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 = () => {
@ -19,6 +20,7 @@ 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')
@ -124,6 +126,11 @@ const AdminMediaPage = () => {
setIsAddModalOpen(true);
};
const handleAICreateSuccess = (importData) => {
setMediaToEdit(importData);
setIsAddModalOpen(true);
};
const handleAIReview = (media) => {
setSelectedMedia(media);
setIsAIReviewModalOpen(true);
@ -169,6 +176,13 @@ const AdminMediaPage = () => {
<FaDownload />
<span>Импорт из TMDB</span>
</button>
<button
onClick={() => setIsAICreateModalOpen(true)}
className="btn-secondary flex items-center gap-2"
>
<FaRobot />
<span>Создать с помощью CampFireAI</span>
</button>
<button
onClick={() => {
setMediaToEdit(null);
@ -327,7 +341,7 @@ const AdminMediaPage = () => {
<Modal
isOpen={isAddModalOpen}
onClose={handleFormCancel}
title="Добавить новый контент"
title={mediaToEdit && mediaToEdit.id ? "Редактировать контент" : "Добавить новый контент"}
size="lg" // Use lg size
>
<MediaForm onSuccess={handleFormSuccess} media={mediaToEdit} />
@ -372,6 +386,19 @@ const AdminMediaPage = () => {
/>
)}
</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;

View File

@ -13,11 +13,10 @@ 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 LoginPage = lazy(() => import('../pages/LoginPage'));
const RegisterPage = lazy(() => import('../pages/RegisterPage'));
const ForgotPasswordPage = lazy(() => import('../pages/ForgotPasswordPage'));
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'));
@ -42,7 +41,6 @@ const ErrorBoundary = ({ children }) => {
const handleError = (error) => {
setHasError(true);
setError(error);
// Здесь можно добавить логирование ошибки
console.error('Route Error:', error);
};
@ -86,21 +84,17 @@ const AppRoutes = () => {
<Route path="/catalog" element={<LazyLoad><CatalogPage /></LazyLoad>} />
<Route path="/media/:path" element={<LazyLoad><MediaOverviewPage /></LazyLoad>} />
<Route path="/rating" element={<LazyLoad><RatingPage /></LazyLoad>} />
<Route path="/forgot-password" element={<LazyLoad><ForgotPasswordPage /></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="/auth" element={<GuestRoute />}>
<Route path="login" element={<LazyLoad><LoginPage /></LazyLoad>} />
<Route path="register" element={<LazyLoad><RegisterPage /></LazyLoad>} />
</Route>
{/* Временно отключена авторизация через Telegram
<Route path="/auth/telegram" element={<TelegramAuth />} />
*/}
<Route path="/admin" element={<AdminRoute />}>
<Route element={<AdminLayout />}>
<Route index element={<LazyLoad><AdminDashboard /></LazyLoad>} />
@ -114,9 +108,7 @@ const AppRoutes = () => {
</Route>
</Route>
<Route path="/support" element={<AuthRoute />}>
<Route index element={<LazyLoad><SupportPage /></LazyLoad>} />
</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>} />

View File

@ -0,0 +1,86 @@
import PocketBase from 'pocketbase';
// Создаем единый экземпляр PocketBase для всех проектов
const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
// Функция для проверки прав доступа к проекту
const checkProjectAccess = async (userId, projectName) => {
try {
const user = await pb.collection('users').getOne(userId);
return user.projects?.includes(projectName) || false;
} catch (error) {
console.error('Ошибка при проверке доступа к проекту:', error);
return false;
}
};
// Функция для входа в систему
const login = async (login, password) => {
try {
const authData = await pb.collection('users').authWithPassword(login, password);
return authData.record;
} catch (error) {
console.error('Ошибка входа:', error);
throw error;
}
};
// Функция для регистрации
const register = async (login, password, email = null, projects = []) => {
try {
const formData = new FormData();
formData.append('login', login);
formData.append('username', login);
formData.append('email', email || '');
formData.append('password', password);
formData.append('passwordConfirm', password);
formData.append('emailVisibility', 'false');
formData.append('role', 'user');
formData.append('projects', JSON.stringify(projects));
formData.append('description', '');
formData.append('review_count', '0');
formData.append('average_rating', '0');
formData.append('xp', '0');
formData.append('level', '1');
formData.append('showcase', '[]');
const record = await pb.collection('users').create(formData);
pb.authStore.clear();
const authData = await pb.collection('users').authWithPassword(login, password);
return { user: authData.record, profile: authData.record };
} catch (error) {
console.error('Ошибка при регистрации:', error);
throw error;
}
};
// Функция для выхода
const logout = () => {
pb.authStore.clear();
};
// Функция для получения текущего пользователя
const getCurrentUser = () => {
return pb.authStore.model;
};
// Функция для обновления профиля пользователя
const updateProfile = async (userId, data) => {
try {
const updatedProfile = await pb.collection('users').update(userId, data);
return updatedProfile;
} catch (error) {
console.error('Ошибка при обновлении профиля:', error);
throw error;
}
};
export {
pb,
login,
register,
logout,
getCurrentUser,
updateProfile,
checkProjectAccess
};

View File

@ -79,7 +79,7 @@ export const getXpForCurrentLevel = (currentLevel) => {
export const signUp = async (login, password, email = null) => {
try {
const formData = new FormData();
formData.append('login', login);
// PocketBase использует username для аутентификации
formData.append('username', login);
formData.append('email', email || '');
formData.append('password', password);
@ -107,21 +107,43 @@ export const signUp = async (login, password, email = null) => {
export const signIn = async (login, password) => {
try {
console.log('PocketBase: Попытка входа с параметрами:', { login, password: '***' });
console.log('PocketBase: URL:', pb.baseUrl);
const authData = await pb.collection('users').authWithPassword(login, password);
console.log('PocketBase: Успешная аутентификация:', authData);
return authData.record;
} catch (error) {
console.error('PocketBase: Ошибка входа:', error);
console.error('PocketBase: Response status:', error.response?.status);
console.error('PocketBase: Response data:', error.response?.data);
// Проверяем различные типы ошибок аутентификации
if (error.response && error.response.data) {
if (error.response.data.message) {
if (error.response.data.message.includes('Invalid login credentials')) {
throw new Error('Неверный логин или пароль');
}
if (error.response.data.message.includes('Failed to authenticate')) {
const message = error.response.data.message.toLowerCase();
// Проверяем все возможные варианты ошибок аутентификации
if (message.includes('invalid login credentials') ||
message.includes('failed to authenticate') ||
message.includes('user not found') ||
message.includes('invalid credentials') ||
message.includes('authentication failed')) {
throw new Error('Неверный логин или пароль');
}
// Если это другая ошибка, показываем её
throw new Error(error.response.data.message);
}
}
// Если не удалось определить тип ошибки, но это ошибка 400 (Bad Request)
// то скорее всего это проблема с аутентификацией
if (error.response && error.response.status === 400) {
throw new Error('Неверный логин или пароль');
}
// Для всех остальных случаев показываем общую ошибку
throw new Error('Произошла ошибка при входе. Пожалуйста, попробуйте позже.');
}
};