8.2 update
This commit is contained in:
parent
881e6da923
commit
7ba6562c74
51
Caddyfile
Normal file
51
Caddyfile
Normal 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
|
||||
}
|
11
campfirecritics_alpha_6_2.code-workspace
Normal file
11
campfirecritics_alpha_6_2.code-workspace
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../campfire-auth"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
BIN
public/icon.png
BIN
public/icon.png
Binary file not shown.
Before Width: | Height: | Size: 506 KiB |
238
src/components/admin/AICreateModal.jsx
Normal file
238
src/components/admin/AICreateModal.jsx
Normal 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;
|
@ -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,6 +99,8 @@ function MediaForm({ media, onSuccess }) {
|
||||
|
||||
// Convert characteristics object from PocketBase to array of { id, key, label }
|
||||
let initialCharacteristicsArray = [];
|
||||
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
|
||||
@ -116,18 +118,67 @@ function MediaForm({ media, onSuccess }) {
|
||||
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);
|
||||
|
||||
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
|
||||
// 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
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
56
src/components/auth/ProjectRoute.jsx
Normal file
56
src/components/auth/ProjectRoute.jsx
Normal 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;
|
@ -6,26 +6,21 @@ import React, {
|
||||
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';
|
||||
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 currentUser = getCurrentUser();
|
||||
|
||||
// Проверяем текущую сессию
|
||||
const authData = pb.authStore.model;
|
||||
if (authData) {
|
||||
setUser(authData);
|
||||
const profile = await pb.collection('users').getOne(authData.id);
|
||||
if (currentUser && mounted) {
|
||||
setUser(currentUser);
|
||||
try {
|
||||
const profile = await pb.collection('users').getOne(currentUser.id);
|
||||
if (mounted) {
|
||||
setUserProfile(profile);
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
} catch (profileError) {
|
||||
console.error('Error fetching user profile:', profileError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка инициализации:', error);
|
||||
} finally {
|
||||
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
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
@ -454,16 +450,6 @@ const MobileMediaOverviewPage = () => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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 = () => {
|
||||
|
@ -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>
|
||||
|
@ -7,19 +7,64 @@ 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'}`}>
|
||||
<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 mb-8 mx-auto transition-all duration-1000 ease-out ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-16'}`} // Увеличено translate-y для большего начального смещения
|
||||
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}
|
||||
@ -29,18 +74,50 @@ const NotFoundPage = () => {
|
||||
>
|
||||
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="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"
|
||||
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)'
|
||||
}}
|
||||
>
|
||||
<FaHome className="mr-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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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>} />
|
||||
|
86
src/services/authService.js
Normal file
86
src/services/authService.js
Normal 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
|
||||
};
|
@ -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('Произошла ошибка при входе. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user