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 { 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 [showPosterCropper, setShowPosterCropper] = useState(false);
|
||||||
const [showBackdropCropper, setShowBackdropCropper] = 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 }
|
// Convert characteristics object from PocketBase to array of { id, key, label }
|
||||||
let initialCharacteristicsArray = [];
|
let initialCharacteristicsArray = [];
|
||||||
|
if (media.id) {
|
||||||
|
// Only parse characteristics for existing media records
|
||||||
try {
|
try {
|
||||||
// Check if characteristics is already an object or needs parsing
|
// Check if characteristics is already an object or needs parsing
|
||||||
const rawCharacteristics = typeof media.characteristics === 'object' && media.characteristics !== null
|
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);
|
console.error("Failed to parse characteristics JSON:", e);
|
||||||
initialCharacteristicsArray = []; // Default to empty array on error
|
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);
|
setCharacteristics(initialCharacteristicsArray);
|
||||||
|
|
||||||
|
if (media.id) {
|
||||||
|
// For existing media, use stored values
|
||||||
setIsPublished(media.is_published ?? false); // Use ?? for null/undefined check
|
setIsPublished(media.is_published ?? false); // Use ?? for null/undefined check
|
||||||
setIsPopular(media.is_popular ?? false); // Populate is_popular
|
setIsPopular(media.is_popular ?? false); // Populate is_popular
|
||||||
setProgressType(media.progress_type || 'completed'); // Populate progress_type, default to 'completed' if null/empty
|
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"
|
// 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] : '');
|
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'));
|
setPosterPreview(getFileUrl(media, 'poster'));
|
||||||
setBackdropPreview(getFileUrl(media, 'backdrop'));
|
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 {
|
} else {
|
||||||
// Reset form for creation
|
// Reset form for creation
|
||||||
|
@ -62,6 +62,62 @@ const TMDBImportModal = ({ onClose, onImport }) => {
|
|||||||
|
|
||||||
const details = await response.json();
|
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 = {
|
const importData = {
|
||||||
title: details.title || details.name,
|
title: details.title || details.name,
|
||||||
@ -69,12 +125,21 @@ const TMDBImportModal = ({ onClose, onImport }) => {
|
|||||||
type: item.media_type === 'movie' ? 'movie' : 'tv',
|
type: item.media_type === 'movie' ? 'movie' : 'tv',
|
||||||
overview: details.overview,
|
overview: details.overview,
|
||||||
release_date: details.release_date || details.first_air_date,
|
release_date: details.release_date || details.first_air_date,
|
||||||
poster_path: details.poster_path,
|
poster: posterFile,
|
||||||
backdrop_path: details.backdrop_path,
|
backdrop: backdropFile,
|
||||||
tmdb_id: details.id,
|
tmdb_id: details.id,
|
||||||
tmdb_type: item.media_type
|
tmdb_type: item.media_type
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Показываем информацию о загруженных изображениях
|
||||||
|
if (posterFile || backdropFile) {
|
||||||
|
console.log('Импорт завершен:', {
|
||||||
|
title: importData.title,
|
||||||
|
poster: posterFile ? '✓ Загружен' : '✗ Не загружен',
|
||||||
|
backdrop: backdropFile ? '✓ Загружен' : '✗ Не загружен'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onImport(importData);
|
onImport(importData);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { Navigate, Outlet } from 'react-router-dom';
|
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();
|
const { user, isInitialized } = useAuth();
|
||||||
|
|
||||||
// Wait for auth state to be initialized
|
// Wait for auth state to be initialized
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
// You might want a loading spinner here
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
|
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
|
<div className="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) {
|
if (!user) {
|
||||||
return <Navigate to="/auth" replace />;
|
return <Navigate to="/auth/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user is authenticated, render the child routes
|
// 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 { 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();
|
const { user, isInitialized } = useAuth();
|
||||||
|
|
||||||
// Wait for auth state to be initialized
|
// Wait for auth state to be initialized
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
// You might want a loading spinner here
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
|
<div className="min-h-screen flex items-center justify-center bg-campfire-dark">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
|
<div className="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
|
// If user is authenticated, redirect them away from guest-only routes
|
||||||
// Redirect to profile page or home page
|
|
||||||
if (user) {
|
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 />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user is not authenticated, render the child routes
|
// If user is not authenticated, render the guest-only content
|
||||||
return <Outlet />;
|
return children || <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GuestRoute;
|
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,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
pb, // Import PocketBase instance for authStore listener
|
pb,
|
||||||
signUp as pbSignUp,
|
signIn,
|
||||||
signIn as pbSignIn,
|
signUp,
|
||||||
signOut as pbSignOut,
|
signOut,
|
||||||
getUserProfile,
|
getCurrentUser
|
||||||
requestPasswordReset as pbRequestPasswordReset, // Import the new function
|
} from '../services/pocketbaseService';
|
||||||
} from "../services/pocketbaseService"; // Use the new service file
|
|
||||||
import { getNotificationSettings } from '../services/notificationService';
|
|
||||||
import { useCache } from '../hooks/useCache';
|
import { useCache } from '../hooks/useCache';
|
||||||
import { useErrorBoundary } from '../hooks/useErrorBoundary';
|
import { useErrorBoundary } from '../hooks/useErrorBoundary';
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
// CORRECT: useContext is called at the top level of the useAuth hook function
|
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
// This error should ideally not happen if AuthProvider is used correctly
|
console.error("useAuth must be used within an AuthProvider");
|
||||||
// and handles its own initialization state before rendering children.
|
|
||||||
console.error("useAuth must be used within an AuthProvider"); // Add error logging
|
|
||||||
throw new Error("useAuth must be used within an AuthProvider");
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
@ -47,6 +42,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const profile = await pb.collection('users').getOne(user.id);
|
const profile = await pb.collection('users').getOne(user.id);
|
||||||
return profile;
|
return profile;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Error fetching profile:', err);
|
||||||
handleError(err);
|
handleError(err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -59,49 +55,85 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
// Инициализация аутентификации
|
// Инициализация аутентификации
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
const initializeAuth = async () => {
|
const initializeAuth = async () => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clearError();
|
const currentUser = getCurrentUser();
|
||||||
|
|
||||||
// Проверяем текущую сессию
|
if (currentUser && mounted) {
|
||||||
const authData = pb.authStore.model;
|
setUser(currentUser);
|
||||||
if (authData) {
|
try {
|
||||||
setUser(authData);
|
const profile = await pb.collection('users').getOne(currentUser.id);
|
||||||
const profile = await pb.collection('users').getOne(authData.id);
|
if (mounted) {
|
||||||
setUserProfile(profile);
|
setUserProfile(profile);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (profileError) {
|
||||||
handleError(err);
|
console.error('Error fetching user profile:', profileError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeAuth();
|
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 {
|
try {
|
||||||
clearError();
|
clearError();
|
||||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
console.log('Attempting login with:', { username, pbUrl: pb.baseUrl });
|
||||||
setUser(authData.record);
|
const authData = await signIn(username, password);
|
||||||
const profile = await pb.collection('users').getOne(authData.record.id);
|
console.log('Login successful:', authData);
|
||||||
|
setUser(authData);
|
||||||
|
const profile = await pb.collection('users').getOne(authData.id);
|
||||||
setUserProfile(profile);
|
setUserProfile(profile);
|
||||||
return profile;
|
return profile;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
handleError(err);
|
handleError(err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}, [clearError, handleError]);
|
}, [clearError, handleError]);
|
||||||
|
|
||||||
// Обработчик выхода
|
// Обработчик выхода
|
||||||
const logout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
clearError();
|
clearError();
|
||||||
pb.authStore.clear();
|
signOut();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -109,26 +141,11 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [clearError, handleError]);
|
}, [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 {
|
try {
|
||||||
clearError();
|
clearError();
|
||||||
const result = await pbSignUp(login, password, email);
|
const result = await signUp(username, password, email);
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
setUserProfile(result.profile);
|
setUserProfile(result.profile);
|
||||||
return result;
|
return result;
|
||||||
@ -144,10 +161,9 @@ export const AuthProvider = ({ children }) => {
|
|||||||
loading,
|
loading,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
error,
|
error,
|
||||||
login,
|
login: handleLogin,
|
||||||
logout,
|
logout: handleLogout,
|
||||||
signUp,
|
register: handleRegister,
|
||||||
updateProfile,
|
|
||||||
refetchProfile
|
refetchProfile
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { getLatestReviews, listMedia, listUsersRankedByReviews, getFileUrl, getMediaCount, getReviewsCount } from '../../services/pocketbaseService';
|
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 GridMotionMobile from '../components/GridMotionMobile';
|
||||||
import CountUp from '../../components/reactbits/TextAnimations/CountUp/CountUp';
|
import CountUp from '../../components/reactbits/TextAnimations/CountUp/CountUp';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
@ -173,23 +173,18 @@ const MobileHome = () => {
|
|||||||
<span>{review.rating}</span>
|
<span>{review.rating}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-campfire-ash">•</span>
|
<span className="text-campfire-ash">•</span>
|
||||||
<span className="text-campfire-ash text-xs">
|
<div className="flex items-center gap-4 text-campfire-ash text-sm">
|
||||||
{new Date(review.created).toLocaleDateString()}
|
<div className="flex items-center">
|
||||||
</span>
|
<FaHeart className="mr-1" />
|
||||||
|
<span>{review.likes?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{new Date(review.created).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-campfire-light/80 line-clamp-2">
|
<p className="text-campfire-light/80 line-clamp-2">
|
||||||
{review.text}
|
{review.text}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useAuth } from '../../contexts/AuthContext';
|
||||||
import { getMediaByPath, getReviewsByMediaId, getFileUrl, likeReview, unlikeReview } from '../../services/pocketbaseService';
|
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';
|
import MobileReviewForm from '../components/MobileReviewForm';
|
||||||
|
|
||||||
const MobileMedia = () => {
|
const MobileMedia = () => {
|
||||||
@ -224,10 +224,6 @@ const MobileMedia = () => {
|
|||||||
)}
|
)}
|
||||||
<span>{review.likes?.length || 0}</span>
|
<span>{review.likes?.length || 0}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
className="flex items-center space-x-1 text-campfire-ash hover:text-campfire-amber"
|
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 React, { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { pb, getFileUrl } from '../../services/pocketbaseService';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import RatingChart from '../../components/reviews/RatingChart';
|
import RatingChart from '../../components/reviews/RatingChart';
|
||||||
@ -421,10 +421,6 @@ const MobileMediaOverviewPage = () => {
|
|||||||
<FaHeart className="mr-1" />
|
<FaHeart className="mr-1" />
|
||||||
<span>{review.likes?.length || 0}</span>
|
<span>{review.likes?.length || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
|
||||||
<FaComment className="mr-1" />
|
|
||||||
<span>{review.comments?.length || 0}</span>
|
|
||||||
</div>
|
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{new Date(review.created).toLocaleDateString()}</span>
|
<span>{new Date(review.created).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -454,16 +450,6 @@ const MobileMediaOverviewPage = () => {
|
|||||||
mediaType={media.type}
|
mediaType={media.type}
|
||||||
progress_type={media.progress_type}
|
progress_type={media.progress_type}
|
||||||
characteristics={media.characteristics}
|
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) => {
|
onSubmit={async (data) => {
|
||||||
try {
|
try {
|
||||||
if (userReview) {
|
if (userReview) {
|
||||||
@ -487,6 +473,21 @@ const MobileMediaOverviewPage = () => {
|
|||||||
console.error('Error saving review:', err);
|
console.error('Error saving review:', err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onEdit={async (reviewId, data) => {
|
||||||
|
try {
|
||||||
|
await pb.collection('reviews').update(reviewId, data);
|
||||||
|
setIsReviewFormOpen(false);
|
||||||
|
// Обновляем данные
|
||||||
|
const reviewsData = await pb.collection('reviews').getList(1, 50, {
|
||||||
|
filter: `media_id="${media.id}"`,
|
||||||
|
sort: '-created',
|
||||||
|
expand: 'user_id'
|
||||||
|
});
|
||||||
|
setReviews(reviewsData.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating review:', err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
try {
|
try {
|
||||||
await pb.collection('reviews').delete(userReview.id);
|
await pb.collection('reviews').delete(userReview.id);
|
||||||
@ -503,6 +504,7 @@ const MobileMediaOverviewPage = () => {
|
|||||||
console.error('Error deleting review:', err);
|
console.error('Error deleting review:', err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
existingReview={userReview}
|
||||||
seasons={seasons}
|
seasons={seasons}
|
||||||
selectedSeasonId={selectedSeasonId}
|
selectedSeasonId={selectedSeasonId}
|
||||||
/>
|
/>
|
||||||
|
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { pb, getFileUrl, getUserAchievements } from '../../services/pocketbaseService';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import ShowcaseThemeModal from '../components/ShowcaseThemeModal';
|
import ShowcaseThemeModal from '../components/ShowcaseThemeModal';
|
||||||
import AchievementsModal from '../components/AchievementsModal';
|
import AchievementsModal from '../components/AchievementsModal';
|
||||||
@ -294,29 +294,17 @@ const MobileProfile = () => {
|
|||||||
<h3 className="text-lg font-semibold text-campfire-light mb-1">
|
<h3 className="text-lg font-semibold text-campfire-light mb-1">
|
||||||
{review.expand?.media_id?.title}
|
{review.expand?.media_id?.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-4 text-campfire-ash text-sm">
|
||||||
<div className="flex items-center text-campfire-amber">
|
<div className="flex items-center">
|
||||||
<FaStar className="mr-1" />
|
<FaHeart className="mr-1" />
|
||||||
<span>{review.rating}</span>
|
<span>{review.likes?.length || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-campfire-ash">•</span>
|
<span>•</span>
|
||||||
<span className="text-campfire-ash text-sm">
|
<span>{new Date(review.created).toLocaleDateString()}</span>
|
||||||
{new Date(review.created).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-campfire-light/80 line-clamp-2">
|
<p className="text-campfire-light/80 line-clamp-2">
|
||||||
{review.text}
|
{review.text}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@ -357,29 +345,17 @@ const MobileProfile = () => {
|
|||||||
<h3 className="text-lg font-semibold text-campfire-light mb-1">
|
<h3 className="text-lg font-semibold text-campfire-light mb-1">
|
||||||
{review.expand?.media_id?.title}
|
{review.expand?.media_id?.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-4 text-campfire-ash text-sm">
|
||||||
<div className="flex items-center text-campfire-amber">
|
<div className="flex items-center">
|
||||||
<FaStar className="mr-1" />
|
<FaHeart className="mr-1" />
|
||||||
<span>{review.rating}</span>
|
<span>{review.likes?.length || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-campfire-ash">•</span>
|
<span>•</span>
|
||||||
<span className="text-campfire-ash text-sm">
|
<span>{new Date(review.created).toLocaleDateString()}</span>
|
||||||
{new Date(review.created).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-campfire-light/80 line-clamp-2">
|
<p className="text-campfire-light/80 line-clamp-2">
|
||||||
{review.text}
|
{review.text}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { pb } from '../../services/pocketbaseService';
|
import { pb, getFileUrl } from '../../services/pocketbaseService';
|
||||||
import { getFileUrl } from '../../services/pocketbaseService';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
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 { formatDistanceToNow } from 'date-fns';
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
|
|
||||||
|
@ -19,8 +19,13 @@ const LazyLoad = ({ children }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const PrivateRoute = ({ children }) => {
|
const PrivateRoute = ({ children }) => {
|
||||||
const { isAuthenticated } = useAuth();
|
const { user, isInitialized } = useAuth();
|
||||||
return isAuthenticated ? children : <Navigate to="/auth/login" />;
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? children : <Navigate to="/auth/login" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MobileRoutes = () => {
|
const MobileRoutes = () => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext'; // Import useAuth
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { getUsers } from '../services/pocketbaseService';
|
import { getUsers } from '../services/pocketbaseService';
|
||||||
import { FaTelegram } from 'react-icons/fa';
|
import { FaTelegram } from 'react-icons/fa';
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ const LoginPage = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { user, userProfile, loading: authLoading, login, isInitialized } = useAuth(); // Get isInitialized
|
const { user, userProfile, loading: authLoading, login, isInitialized } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && isInitialized) {
|
if (!authLoading && isInitialized) {
|
||||||
@ -83,6 +83,9 @@ const LoginPage = () => {
|
|||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-campfire-ash mt-1">
|
||||||
|
Логин чувствителен к регистру букв
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -7,19 +7,64 @@ import catImage from '../assets/cat.png';
|
|||||||
|
|
||||||
const NotFoundPage = () => {
|
const NotFoundPage = () => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isScaled, setIsScaled] = useState(false);
|
||||||
|
const [showContent, setShowContent] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
|
const timer1 = setTimeout(() => {
|
||||||
|
setShowContent(true);
|
||||||
|
}, 800);
|
||||||
|
const timer2 = setTimeout(() => {
|
||||||
|
setIsScaled(true);
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer1);
|
||||||
|
clearTimeout(timer2);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-campfire-dark flex flex-col items-center justify-center p-4">
|
<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={`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
|
<img
|
||||||
src={catImage}
|
src={catImage}
|
||||||
alt="Кот в капюшоне"
|
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 для большего начального смещения
|
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
|
<FuzzyText
|
||||||
fontSize="clamp(4rem, 15vw, 12rem)"
|
fontSize="clamp(4rem, 15vw, 12rem)"
|
||||||
fontWeight={900}
|
fontWeight={900}
|
||||||
@ -29,18 +74,50 @@ const NotFoundPage = () => {
|
|||||||
>
|
>
|
||||||
404
|
404
|
||||||
</FuzzyText>
|
</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 className="text-campfire-light text-xl mt-4 mb-8">
|
||||||
Страница не найдена
|
Страница не найдена
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ function RegisterPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { user, userProfile, loading: authLoading, signUp, isInitialized } = useAuth();
|
const { user, userProfile, loading: authLoading, register, isInitialized } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -52,7 +52,7 @@ function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await signUp(login, password, email || null);
|
await register(login, password, email || null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response && err.response.data) {
|
if (err.response && err.response.data) {
|
||||||
const errorData = err.response.data;
|
const errorData = err.response.data;
|
||||||
@ -117,6 +117,9 @@ function RegisterPage() {
|
|||||||
<p className="text-xs text-campfire-ash mt-1">
|
<p className="text-xs text-campfire-ash mt-1">
|
||||||
От 3 до 20 символов, только латинские буквы, цифры и _
|
От 3 до 20 символов, только латинские буквы, цифры и _
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-campfire-ash mt-1">
|
||||||
|
Логин чувствителен к регистру букв
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -6,6 +6,7 @@ import Modal from '../../components/common/Modal';
|
|||||||
import MediaForm from '../../components/admin/MediaForm';
|
import MediaForm from '../../components/admin/MediaForm';
|
||||||
import TMDBImportModal from '../../components/admin/TMDBImportModal';
|
import TMDBImportModal from '../../components/admin/TMDBImportModal';
|
||||||
import AIReviewModal from '../../components/admin/AIReviewModal';
|
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
|
import { FaEdit, FaTrashAlt, FaPlus, FaTv, FaDownload, FaRobot } from 'react-icons/fa'; // Import FaTv icon
|
||||||
|
|
||||||
const AdminMediaPage = () => {
|
const AdminMediaPage = () => {
|
||||||
@ -19,6 +20,7 @@ const AdminMediaPage = () => {
|
|||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||||
|
const [isAICreateModalOpen, setIsAICreateModalOpen] = useState(false);
|
||||||
const [mediaToEdit, setMediaToEdit] = useState(null);
|
const [mediaToEdit, setMediaToEdit] = useState(null);
|
||||||
const [filterType, setFilterType] = useState(''); // State for type filter
|
const [filterType, setFilterType] = useState(''); // State for type filter
|
||||||
const [filterPublished, setFilterPublished] = useState(''); // State for published filter ('', 'true', 'false')
|
const [filterPublished, setFilterPublished] = useState(''); // State for published filter ('', 'true', 'false')
|
||||||
@ -124,6 +126,11 @@ const AdminMediaPage = () => {
|
|||||||
setIsAddModalOpen(true);
|
setIsAddModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAICreateSuccess = (importData) => {
|
||||||
|
setMediaToEdit(importData);
|
||||||
|
setIsAddModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAIReview = (media) => {
|
const handleAIReview = (media) => {
|
||||||
setSelectedMedia(media);
|
setSelectedMedia(media);
|
||||||
setIsAIReviewModalOpen(true);
|
setIsAIReviewModalOpen(true);
|
||||||
@ -169,6 +176,13 @@ const AdminMediaPage = () => {
|
|||||||
<FaDownload />
|
<FaDownload />
|
||||||
<span>Импорт из TMDB</span>
|
<span>Импорт из TMDB</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAICreateModalOpen(true)}
|
||||||
|
className="btn-secondary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FaRobot />
|
||||||
|
<span>Создать с помощью CampFireAI</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMediaToEdit(null);
|
setMediaToEdit(null);
|
||||||
@ -327,7 +341,7 @@ const AdminMediaPage = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isAddModalOpen}
|
isOpen={isAddModalOpen}
|
||||||
onClose={handleFormCancel}
|
onClose={handleFormCancel}
|
||||||
title="Добавить новый контент"
|
title={mediaToEdit && mediaToEdit.id ? "Редактировать контент" : "Добавить новый контент"}
|
||||||
size="lg" // Use lg size
|
size="lg" // Use lg size
|
||||||
>
|
>
|
||||||
<MediaForm onSuccess={handleFormSuccess} media={mediaToEdit} />
|
<MediaForm onSuccess={handleFormSuccess} media={mediaToEdit} />
|
||||||
@ -372,6 +386,19 @@ const AdminMediaPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* AI Create Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isAICreateModalOpen}
|
||||||
|
onClose={() => setIsAICreateModalOpen(false)}
|
||||||
|
title="Создать с помощью CampFireAI"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<AICreateModal
|
||||||
|
onClose={() => setIsAICreateModalOpen(false)}
|
||||||
|
onImport={handleAICreateSuccess}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</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 MediaOverviewPage = lazy(() => import('../pages/MediaOverviewPage'));
|
||||||
const ProfilePage = lazy(() => import('../pages/ProfilePage'));
|
const ProfilePage = lazy(() => import('../pages/ProfilePage'));
|
||||||
const RatingPage = lazy(() => import('../pages/RatingPage'));
|
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 SupportPage = lazy(() => import('../pages/SupportPage'));
|
||||||
const NotFoundPage = lazy(() => import('../pages/NotFoundPage'));
|
const NotFoundPage = lazy(() => import('../pages/NotFoundPage'));
|
||||||
|
const LoginPage = lazy(() => import('../pages/LoginPage'));
|
||||||
|
const RegisterPage = lazy(() => import('../pages/RegisterPage'));
|
||||||
|
|
||||||
// Админские страницы
|
// Админские страницы
|
||||||
const AdminDashboard = lazy(() => import('../pages/admin/AdminDashboard'));
|
const AdminDashboard = lazy(() => import('../pages/admin/AdminDashboard'));
|
||||||
@ -42,7 +41,6 @@ const ErrorBoundary = ({ children }) => {
|
|||||||
const handleError = (error) => {
|
const handleError = (error) => {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
setError(error);
|
setError(error);
|
||||||
// Здесь можно добавить логирование ошибки
|
|
||||||
console.error('Route Error:', error);
|
console.error('Route Error:', error);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -86,21 +84,17 @@ const AppRoutes = () => {
|
|||||||
<Route path="/catalog" element={<LazyLoad><CatalogPage /></LazyLoad>} />
|
<Route path="/catalog" element={<LazyLoad><CatalogPage /></LazyLoad>} />
|
||||||
<Route path="/media/:path" element={<LazyLoad><MediaOverviewPage /></LazyLoad>} />
|
<Route path="/media/:path" element={<LazyLoad><MediaOverviewPage /></LazyLoad>} />
|
||||||
<Route path="/rating" element={<LazyLoad><RatingPage /></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" element={<AuthRoute><LazyLoad><ProfilePage /></LazyLoad></AuthRoute>} />
|
||||||
<Route path="/profile/:login" element={<LazyLoad><ProfilePage /></LazyLoad>} />
|
<Route path="/profile/:login" element={<LazyLoad><ProfilePage /></LazyLoad>} />
|
||||||
<Route path="/profile/telegram-link" element={<AuthRoute><TelegramAuth /></AuthRoute>} />
|
<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 path="/admin" element={<AdminRoute />}>
|
||||||
<Route element={<AdminLayout />}>
|
<Route element={<AdminLayout />}>
|
||||||
<Route index element={<LazyLoad><AdminDashboard /></LazyLoad>} />
|
<Route index element={<LazyLoad><AdminDashboard /></LazyLoad>} />
|
||||||
@ -114,9 +108,7 @@ const AppRoutes = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/support" element={<AuthRoute />}>
|
<Route path="/support" element={<AuthRoute><LazyLoad><SupportPage /></LazyLoad></AuthRoute>} />
|
||||||
<Route index element={<LazyLoad><SupportPage /></LazyLoad>} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/privacy-policy" element={<LazyLoad><PrivacyPolicyPage /></LazyLoad>} />
|
<Route path="/privacy-policy" element={<LazyLoad><PrivacyPolicyPage /></LazyLoad>} />
|
||||||
<Route path="/terms-of-service" element={<LazyLoad><TermsOfServicePage /></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) => {
|
export const signUp = async (login, password, email = null) => {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('login', login);
|
// PocketBase использует username для аутентификации
|
||||||
formData.append('username', login);
|
formData.append('username', login);
|
||||||
formData.append('email', email || '');
|
formData.append('email', email || '');
|
||||||
formData.append('password', password);
|
formData.append('password', password);
|
||||||
@ -107,21 +107,43 @@ export const signUp = async (login, password, email = null) => {
|
|||||||
|
|
||||||
export const signIn = async (login, password) => {
|
export const signIn = async (login, password) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('PocketBase: Попытка входа с параметрами:', { login, password: '***' });
|
||||||
|
console.log('PocketBase: URL:', pb.baseUrl);
|
||||||
|
|
||||||
const authData = await pb.collection('users').authWithPassword(login, password);
|
const authData = await pb.collection('users').authWithPassword(login, password);
|
||||||
|
console.log('PocketBase: Успешная аутентификация:', authData);
|
||||||
return authData.record;
|
return authData.record;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PocketBase: Ошибка входа:', 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 && error.response.data) {
|
||||||
if (error.response.data.message) {
|
if (error.response.data.message) {
|
||||||
if (error.response.data.message.includes('Invalid login credentials')) {
|
const message = error.response.data.message.toLowerCase();
|
||||||
throw new Error('Неверный логин или пароль');
|
|
||||||
}
|
// Проверяем все возможные варианты ошибок аутентификации
|
||||||
if (error.response.data.message.includes('Failed to authenticate')) {
|
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('Неверный логин или пароль');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если это другая ошибка, показываем её
|
||||||
throw new Error(error.response.data.message);
|
throw new Error(error.response.data.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если не удалось определить тип ошибки, но это ошибка 400 (Bad Request)
|
||||||
|
// то скорее всего это проблема с аутентификацией
|
||||||
|
if (error.response && error.response.status === 400) {
|
||||||
|
throw new Error('Неверный логин или пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для всех остальных случаев показываем общую ошибку
|
||||||
throw new Error('Произошла ошибка при входе. Пожалуйста, попробуйте позже.');
|
throw new Error('Произошла ошибка при входе. Пожалуйста, попробуйте позже.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user