Initial commit

This commit is contained in:
degradin 2025-05-07 10:48:06 +03:00
commit daa4ab85d4
40 changed files with 9898 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

48
.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# Environment variables
.env
.env.local
.env.*.local
# Dependencies
/node_modules
/.pnp
.pnp.js
# Production
/build
/dist
# Misc
.DS_Store
*.pem
.vscode
.idea
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
# Logs
logs
*.log
pnpm-debug.log*
lerna-debug.log*
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
README.md Normal file
View File

@ -0,0 +1,8 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

41
eslint.config.js Normal file
View File

@ -0,0 +1,41 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
process: 'readonly'
},
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CampFire Critics Web Application</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6235
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "campfire-critics",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.39.3",
"@neondatabase/serverless": "^0.9.0",
"axios": "^1.6.7",
"chart.js": "^4.4.1",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.2"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.17",
"eslint": "^9.9.1",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1 @@
https://images.pexels.com/photos/7991579/pexels-photo-7991579.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

40
src/App.jsx Normal file
View File

@ -0,0 +1,40 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import { MediaProvider } from "./contexts/MediaContext";
import Header from "./components/layout/Header";
import Footer from "./components/layout/Footer";
import HomePage from "./pages/HomePage";
import MediaPage from "./pages/MediaPage";
import ProfilePage from "./pages/ProfilePage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import NotFoundPage from "./pages/NotFoundPage";
import AdminMediaPage from "./pages/AdminMediaPage";
function App() {
return (
<Router>
<AuthProvider>
<MediaProvider>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/media/:id" element={<MediaPage />} />
<Route path="/profile/:id" element={<ProfilePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/admin/media/new" element={<AdminMediaPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<Footer />
</div>
</MediaProvider>
</AuthProvider>
</Router>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,166 @@
import { FaDiscord, FaTelegramPlane, FaFire } from "react-icons/fa";
import {
RiOpenaiFill,
RiGeminiLine,
RiStackOverflowLine,
} from "react-icons/ri";
import { Link } from "react-router-dom";
import Logo from "../ui/Logo";
function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-campfire-charcoal py-12 mt-20">
<div className="container-custom">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="col-span-1 md:col-span-1">
<Link to="/" className="flex items-center">
<Logo size="small" />
<span className="ml-2 text-xl font-bold">CampFire Critics</span>
</Link>
<p className="mt-4 text-campfire-ash">
Делаем хорошо, но на отъебись.
</p>
<div className="flex mt-6 space-x-4">
<a
href="#"
className="text-campfire-ash hover:text-campfire-amber"
>
<FaDiscord size={20} />
</a>
<a
href="#"
className="text-campfire-ash hover:text-campfire-amber"
>
<FaTelegramPlane size={20} />
</a>
<a
href="#"
className="text-campfire-ash hover:text-campfire-amber"
>
<FaFire size={20} />
</a>
</div>
</div>
{/* Navigation */}
<div className="col-span-1">
<h3 className="text-lg font-semibold mb-4">Атлас</h3>
<ul className="space-y-2">
<li>
<Link
to="/"
className="text-campfire-ash hover:text-campfire-amber"
>
Главная
</Link>
</li>
<li>
<Link
to="/discover/movies"
className="text-campfire-ash hover:text-campfire-amber"
>
Фильмы
</Link>
</li>
<li>
<Link
to="/discover/tv"
className="text-campfire-ash hover:text-campfire-amber"
>
Сериалы
</Link>
</li>
<li>
<Link
to="/discover/games"
className="text-campfire-ash hover:text-campfire-amber"
>
Игры
</Link>
</li>
</ul>
</div>
{/* Legal */}
<div className="col-span-1">
<h3 className="text-lg font-semibold mb-4">Правовая информация</h3>
<ul className="space-y-2">
<li>
<Link
to="/terms"
className="text-campfire-ash hover:text-campfire-amber"
>
Пользовательское соглашение
</Link>
</li>
<li>
<Link
to="/privacy"
className="text-campfire-ash hover:text-campfire-amber"
>
Конфиденциальность
</Link>
</li>
<li>
<Link
to="/cookies"
className="text-campfire-ash hover:text-campfire-amber"
>
Куки
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div className="col-span-1">
<h3 className="text-lg font-semibold mb-4">Контакты</h3>
<ul className="space-y-2">
<li className="text-campfire-ash">
<span>general@campfiregg.ru</span>
</li>
<li>
<Link
to="/contact"
className="text-campfire-ash hover:text-campfire-amber"
>
Связаться с нами
</Link>
</li>
<li>
<Link
to="/faq"
className="text-campfire-ash hover:text-campfire-amber"
>
FAQ
</Link>
</li>
</ul>
</div>
</div>
<div className="border-t border-campfire-dark mt-12 pt-8 text-center text-campfire-ash">
<p>
&copy; {currentYear} CampFire Critics. Почти все права защищены.
</p>
<p className="mt-2 text-sm">
<a
href="#"
className="text-campfire-ash hover:text-campfire-amber inline-flex items-center"
>
VibeCoded with
<RiStackOverflowLine className="ml-1" size={20} />
<RiGeminiLine className="ml-1" size={20} />
<RiOpenaiFill className="ml-1" size={20} />
</a>
</p>
</div>
</div>
</footer>
);
}
export default Footer;

View File

@ -0,0 +1,191 @@
import { useState, useEffect } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { FiSearch, FiMenu, FiX, FiUser } from "react-icons/fi";
import SearchBar from "../ui/SearchBar";
import Logo from "../ui/Logo";
function Header() {
const [isScrolled, setIsScrolled] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { currentUser, userProfile, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Close mobile menu when route changes
useEffect(() => {
setIsMenuOpen(false);
setIsSearchOpen(false);
}, [location.pathname]);
// Handle scroll effect for header
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 10) {
setIsScrolled(true);
} else {
setIsScrolled(false);
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleLogout = async () => {
try {
await logout();
navigate("/");
} catch (error) {
console.error("Не удалось войти", error);
}
};
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled ? "bg-campfire-dark shadow-lg py-2" : "bg-transparent py-4"
}`}
>
<div className="container-custom">
<div className="flex items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center">
<Logo />
<span className="ml-2 text-xl font-bold hidden sm:block">
CampFire Critics
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6">
<Link
to="/"
className="text-campfire-light hover:text-campfire-amber transition-colors"
>
Главная
</Link>
<Link
to="/discover/movies"
className="text-campfire-light hover:text-campfire-amber transition-colors"
>
Фильмы
</Link>
<Link
to="/discover/tv"
className="text-campfire-light hover:text-campfire-amber transition-colors"
>
Сериалы
</Link>
<Link
to="/discover/games"
className="text-campfire-light hover:text-campfire-amber transition-colors"
>
Игры
</Link>
</nav>
{/* Search and User Actions */}
<div className="flex items-center space-x-4">
<button
onClick={() => setIsSearchOpen(!isSearchOpen)}
className="p-2 text-campfire-light hover:text-campfire-amber"
aria-label="Найти"
>
<FiSearch size={20} />
</button>
{currentUser ? (
<div className="relative group">
<button className="flex items-center space-x-2 p-2 rounded-full bg-campfire-charcoal">
{userProfile?.profilePicture ? (
<img
src={userProfile.profilePicture}
alt={userProfile.username}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<FiUser size={20} className="text-campfire-light" />
)}
</button>
<div className="absolute right-0 mt-2 w-48 py-2 bg-campfire-charcoal rounded-md shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300">
<Link
to={`/profile/${currentUser.uid}`}
className="block px-4 py-2 hover:bg-campfire-dark"
>
Профиль
</Link>
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 hover:bg-campfire-dark"
>
Выйти
</button>
</div>
</div>
) : (
<Link to="/login" className="btn-primary">
Войти
</Link>
)}
{/* Mobile Menu Button */}
<button
className="md:hidden p-2 text-campfire-light"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label="Меню"
>
{isMenuOpen ? <FiX size={24} /> : <FiMenu size={24} />}
</button>
</div>
</div>
{/* Search Bar (expandable) */}
<div
className={`mt-4 transition-all duration-300 overflow-hidden ${
isSearchOpen ? "max-h-20 opacity-100" : "max-h-0 opacity-0"
}`}
>
<SearchBar onClose={() => setIsSearchOpen(false)} />
</div>
{/* Mobile Menu */}
<div
className={`md:hidden transition-all duration-300 overflow-hidden ${
isMenuOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
}`}
>
<nav className="mt-4 flex flex-col space-y-3 pb-4">
<Link
to="/"
className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
>
Главная
</Link>
<Link
to="/discover/movies"
className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
>
Фильмы
</Link>
<Link
to="/discover/tv"
className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
>
Сериалы
</Link>
<Link
to="/discover/games"
className="text-campfire-light hover:text-campfire-amber transition-colors py-2"
>
Игры
</Link>
</nav>
</div>
</div>
</header>
);
}
export default Header;

View File

@ -0,0 +1,37 @@
import { Link } from 'react-router-dom';
import { FaStar } from 'react-icons/fa';
function MediaCard({ media }) {
const { id, title, poster, rating, releaseDate, type } = media;
return (
<Link to={`/media/${id}`} className="block">
<div className="card group h-full">
<div className="relative overflow-hidden aspect-[2/3]">
<img
src={poster}
alt={title}
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
<div className="absolute top-2 right-2 bg-campfire-dark bg-opacity-75 rounded-full px-2 py-1 flex items-center">
<FaStar className="text-campfire-amber mr-1" size={14} />
<span className="text-sm font-medium">
{rating ? (rating / 2).toFixed(1) : 'N/A'}
</span>
</div>
</div>
<div className="p-4">
<h3 className="font-bold text-campfire-light line-clamp-1 mb-1">
{title}
</h3>
<div className="text-sm text-campfire-ash">
{new Date(releaseDate).getFullYear()}
</div>
</div>
</div>
</Link>
);
}
export default MediaCard;

View File

@ -0,0 +1,76 @@
import { useRef } from 'react';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import MediaCard from './MediaCard';
function MediaCarousel({ title, media = [], mediaType = 'movie', seeAllLink }) {
const carouselRef = useRef(null);
const scroll = (direction) => {
if (carouselRef.current) {
const { current } = carouselRef;
const scrollAmount = direction === 'left'
? -current.offsetWidth * 0.8
: current.offsetWidth * 0.8;
current.scrollBy({
left: scrollAmount,
behavior: 'smooth',
});
}
};
if (!media.length) {
return null;
}
return (
<div className="my-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">{title}</h2>
{seeAllLink && (
<a href={seeAllLink} className="text-campfire-amber hover:text-campfire-ember">
See All
</a>
)}
</div>
<div className="relative">
{/* Left Navigation Arrow */}
<button
onClick={() => scroll('left')}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-campfire-dark bg-opacity-50 hover:bg-opacity-75 p-2 rounded-full text-campfire-light transition-all transform -translate-x-1/2 opacity-0 group-hover:opacity-100 group-hover:translate-x-0 duration-300"
aria-label="Scroll left"
>
<FiChevronLeft size={24} />
</button>
{/* Carousel Container */}
<div
ref={carouselRef}
className="flex overflow-x-auto pb-4 -mx-4 px-4 scrollbar-hide group"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{media.map((item) => (
<div key={item.id} className="min-w-[180px] md:min-w-[220px] px-2 flex-shrink-0">
<MediaCard media={item} type={mediaType} />
</div>
))}
</div>
{/* Right Navigation Arrow */}
<button
onClick={() => scroll('right')}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-campfire-dark bg-opacity-50 hover:bg-opacity-75 p-2 rounded-full text-campfire-light transition-all transform translate-x-1/2 opacity-0 group-hover:opacity-100 group-hover:translate-x-0 duration-300"
aria-label="Scroll right"
>
<FiChevronRight size={24} />
</button>
</div>
</div>
);
}
export default MediaCarousel;

View File

@ -0,0 +1,132 @@
import {
Chart as ChartJS,
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
} from 'chart.js';
import { Radar } from 'react-chartjs-2';
// Register ChartJS components
ChartJS.register(
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
);
function RatingChart({ ratings, size = 'medium', showLegend = false }) {
// Default ratings if none provided
const defaultRatings = {
story: 0,
visuals: 0,
performance: 0,
soundtrack: 0,
enjoyment: 0
};
// Merge provided ratings with defaults
const mergedRatings = { ...defaultRatings, ...ratings };
// Chart data
const data = {
labels: ['Story', 'Visuals', 'Performance', 'Soundtrack', 'Enjoyment'],
datasets: [
{
label: 'Rating',
data: [
mergedRatings.story,
mergedRatings.visuals,
mergedRatings.performance,
mergedRatings.soundtrack,
mergedRatings.enjoyment
],
backgroundColor: 'rgba(255, 157, 0, 0.2)',
borderColor: 'rgba(255, 157, 0, 1)',
borderWidth: 1,
pointBackgroundColor: 'rgba(255, 157, 0, 1)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgba(255, 69, 0, 1)',
}
]
};
// Chart options
const options = {
scales: {
r: {
angleLines: {
display: true,
color: 'rgba(255, 255, 255, 0.1)',
},
suggestedMin: 0,
suggestedMax: 10,
ticks: {
stepSize: 2,
backdropColor: 'transparent',
color: 'rgba(255, 255, 255, 0.7)',
},
grid: {
color: 'rgba(255, 255, 255, 0.1)',
},
pointLabels: {
color: 'rgba(255, 255, 255, 0.7)',
font: {
size: size === 'small' ? 8 : 12,
},
},
}
},
plugins: {
legend: {
display: showLegend,
},
tooltip: {
backgroundColor: 'rgba(45, 55, 72, 0.9)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
bodyFont: {
size: 14,
},
titleFont: {
size: 16,
weight: 'bold',
},
padding: 10,
displayColors: false,
callbacks: {
label: function(context) {
return `Rating: ${context.raw}/10`;
}
}
}
},
elements: {
line: {
tension: 0.2
}
},
responsive: true,
maintainAspectRatio: true,
};
// Size classes
const sizeClasses = {
small: 'w-32 h-32',
medium: 'w-64 h-64',
large: 'w-96 h-96'
};
return (
<div className={`${sizeClasses[size]} mx-auto`}>
<Radar data={data} options={options} />
</div>
);
}
export default RatingChart;

View File

@ -0,0 +1,122 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { FaThumbsUp, FaComment, FaShare } from 'react-icons/fa';
import RatingChart from './RatingChart';
function ReviewCard({ review, isDetailed = false }) {
const [isExpanded, setIsExpanded] = useState(false);
const {
id,
user,
content,
ratings,
likes,
comments,
createdAt,
spoiler
} = review;
// Format date
const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
// Calculate overall rating
const overallRating = Object.values(ratings).reduce((sum, rating) => sum + rating, 0) / Object.keys(ratings).length;
// Handle content length
const isLongContent = content.length > 300;
const displayContent = isLongContent && !isExpanded ? `${content.substring(0, 280)}...` : content;
return (
<div className={`bg-campfire-charcoal rounded-lg shadow-lg p-6 mb-6 ${isDetailed ? 'border border-campfire-ash' : ''}`}>
{/* Review Header */}
<div className="flex justify-between items-start mb-4">
<div className="flex items-center">
<Link to={`/profile/${user.id}`}>
<img
src={user.profilePicture || 'https://via.placeholder.com/40'}
alt={user.username}
className="w-10 h-10 rounded-full object-cover mr-3"
/>
</Link>
<div>
<Link to={`/profile/${user.id}`} className="font-medium text-campfire-light hover:text-campfire-amber">
{user.username}
</Link>
{user.isCritic && (
<span className="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-campfire-amber text-campfire-dark rounded-full">
Critic
</span>
)}
<div className="text-sm text-campfire-ash">{formattedDate}</div>
</div>
</div>
<div className="flex items-center">
<span className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-campfire-amber text-campfire-dark font-bold">
{overallRating.toFixed(1)}
</span>
</div>
</div>
{/* Review Content */}
<div className={`${isDetailed ? 'grid grid-cols-1 md:grid-cols-3 gap-8' : ''}`}>
<div className={`${isDetailed ? 'md:col-span-2' : ''}`}>
{/* Spoiler Warning */}
{spoiler && (
<div className="bg-status-warning bg-opacity-20 text-status-warning p-3 rounded-md mb-4">
<span className="font-bold">Spoiler Warning:</span> This review contains spoilers.
</div>
)}
{/* Review Text */}
<p className="text-campfire-light mb-4">
{displayContent}
</p>
{/* Expand/Collapse for long content */}
{isLongContent && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-campfire-amber hover:text-campfire-ember mb-4 text-sm font-medium"
>
{isExpanded ? 'Show Less' : 'Read More'}
</button>
)}
</div>
{/* Rating Chart */}
<div className={`${isDetailed ? 'md:col-span-1' : 'hidden md:block'}`}>
<RatingChart
ratings={ratings}
size={isDetailed ? 'medium' : 'small'}
showLegend={isDetailed}
/>
</div>
</div>
{/* Review Footer */}
<div className="flex items-center justify-between mt-6 pt-4 border-t border-campfire-dark">
<div className="flex space-x-6">
<button className="flex items-center text-campfire-ash hover:text-campfire-amber">
<FaThumbsUp className="mr-2" />
<span>{likes}</span>
</button>
<button className="flex items-center text-campfire-ash hover:text-campfire-amber">
<FaComment className="mr-2" />
<span>{comments.length}</span>
</button>
</div>
<button className="flex items-center text-campfire-ash hover:text-campfire-amber">
<FaShare className="mr-2" />
<span>Share</span>
</button>
</div>
</div>
);
}
export default ReviewCard;

View File

@ -0,0 +1,157 @@
import { useState } from 'react';
import { FaStar } from 'react-icons/fa';
import RatingChart from './RatingChart';
function ReviewForm({ mediaId, mediaType, onSubmit }) {
const [ratings, setRatings] = useState({
story: 5,
visuals: 5,
performance: 5,
soundtrack: 5,
enjoyment: 5
});
const [content, setContent] = useState('');
const [hasSpoilers, setHasSpoilers] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const labels = {
story: 'Story & Writing',
visuals: 'Visuals & Effects',
performance: 'Acting/Performance',
soundtrack: 'Sound & Music',
enjoyment: 'Overall Enjoyment'
};
const handleRatingChange = (category, value) => {
setRatings(prev => ({
...prev,
[category]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
// Calculate overall rating
const overallRating = Object.values(ratings).reduce((sum, rating) => sum + rating, 0) / Object.keys(ratings).length;
// Prepare review data
const reviewData = {
mediaId,
mediaType,
content,
ratings,
overallRating,
hasSpoilers,
createdAt: new Date().toISOString()
};
await onSubmit(reviewData);
// Reset form
setContent('');
setRatings({
story: 5,
visuals: 5,
performance: 5,
soundtrack: 5,
enjoyment: 5
});
setHasSpoilers(false);
} catch (error) {
console.error('Error submitting review:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold mb-6">Write Your Review</h2>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-6">
<div className="md:col-span-2">
{/* Rating Sliders */}
<div className="space-y-6 mb-6">
{Object.keys(ratings).map(category => (
<div key={category} className="space-y-2">
<div className="flex justify-between">
<label className="text-campfire-light">{labels[category]}</label>
<span className="flex items-center text-campfire-amber">
<FaStar className="mr-1" />
{ratings[category]}
</span>
</div>
<input
type="range"
min="1"
max="10"
value={ratings[category]}
onChange={(e) => handleRatingChange(category, parseInt(e.target.value))}
className="w-full h-2 bg-campfire-dark rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #FF9D00 0%, #FF9D00 ${(ratings[category] - 1) * 11.1}%, #2D3748 ${(ratings[category] - 1) * 11.1}%, #2D3748 100%)`
}}
/>
</div>
))}
</div>
{/* Review Text */}
<div className="mb-6">
<label htmlFor="review-content" className="block mb-2 text-campfire-light">
Your Review
</label>
<textarea
id="review-content"
rows="8"
placeholder="Share your thoughts on this title..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full p-3 bg-campfire-dark border border-campfire-ash rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent"
required
/>
</div>
{/* Spoiler Checkbox */}
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="spoiler-check"
checked={hasSpoilers}
onChange={(e) => setHasSpoilers(e.target.checked)}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2"
/>
<label htmlFor="spoiler-check" className="ml-2 text-campfire-light">
This review contains spoilers
</label>
</div>
</div>
{/* Rating Chart Preview */}
<div className="md:col-span-1">
<p className="text-center text-campfire-light mb-4">Your Rating Preview</p>
<RatingChart ratings={ratings} size="medium" />
</div>
</div>
{/* Submit Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="btn-primary"
>
{isSubmitting ? 'Submitting...' : 'Submit Review'}
</button>
</div>
</form>
</div>
);
}
export default ReviewForm;

View File

@ -0,0 +1,17 @@
import { FaFire } from 'react-icons/fa';
function Logo({ size = "default" }) {
const sizeClasses = {
small: "w-6 h-6",
default: "w-8 h-8",
large: "w-12 h-12"
};
return (
<div className={`relative rounded-full flex items-center justify-center ${sizeClasses[size]}`}>
<FaFire className="text-campfire-amber animate-flicker" size={size === "small" ? 16 : size === "large" ? 32 : 24} />
</div>
);
}
export default Logo;

View File

@ -0,0 +1,119 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiSearch, FiX } from 'react-icons/fi';
import { useMedia } from '../../contexts/MediaContext';
function SearchBar({ onClose }) {
const [query, setQuery] = useState('');
const { handleSearch, searchResults, loading } = useMedia();
const navigate = useNavigate();
const handleSubmit = (e) => {
e.preventDefault();
if (query.trim()) {
navigate(`/search?q=${encodeURIComponent(query)}`);
if (onClose) onClose();
}
};
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
// Debounce search as user types
const timeoutId = setTimeout(() => {
if (value.trim().length > 2) {
handleSearch(value);
}
}, 500);
return () => clearTimeout(timeoutId);
};
const handleResultClick = (id, mediaType) => {
navigate(`/media/${id}?type=${mediaType}`);
if (onClose) onClose();
};
return (
<div className="relative">
<form onSubmit={handleSubmit} className="relative">
<input
type="text"
value={query}
onChange={handleChange}
placeholder="Search for movies, TV shows, games..."
className="w-full bg-campfire-dark border border-campfire-ash rounded-full px-5 py-3 pl-12 text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent outline-none"
/>
<FiSearch className="absolute left-4 top-1/2 transform -translate-y-1/2 text-campfire-ash" size={18} />
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-16 top-1/2 transform -translate-y-1/2 text-campfire-ash hover:text-campfire-light"
>
<FiX size={18} />
</button>
)}
<button
type="submit"
className="absolute right-3 top-1/2 transform -translate-y-1/2 bg-campfire-amber text-campfire-dark rounded-full px-4 py-1 text-sm font-medium hover:bg-campfire-ember transition-colors"
>
Search
</button>
</form>
{/* Quick results dropdown */}
{query.trim().length > 2 && searchResults.length > 0 && (
<div className="absolute z-10 mt-2 w-full bg-campfire-charcoal rounded-md shadow-lg max-h-96 overflow-y-auto">
<div className="py-2">
{loading ? (
<div className="px-4 py-2 text-campfire-ash">Loading...</div>
) : (
searchResults.slice(0, 5).map((item) => (
<div
key={item.id}
onClick={() => handleResultClick(item.id, item.media_type)}
className="px-4 py-2 hover:bg-campfire-dark cursor-pointer flex items-center"
>
{item.poster_path ? (
<img
src={`https://image.tmdb.org/t/p/w92${item.poster_path}`}
alt={item.title || item.name}
className="w-10 h-14 object-cover rounded mr-3"
/>
) : (
<div className="w-10 h-14 bg-campfire-dark rounded mr-3 flex items-center justify-center text-campfire-ash">
No image
</div>
)}
<div>
<div className="font-medium text-campfire-light">
{item.title || item.name}
</div>
<div className="text-sm text-campfire-ash">
{item.media_type === 'movie' ? 'Movie' : 'TV Show'} {item.release_date?.substring(0, 4) || item.first_air_date?.substring(0, 4) || 'Unknown'}
</div>
</div>
</div>
))
)}
{searchResults.length > 5 && (
<div
className="px-4 py-2 text-center text-campfire-amber cursor-pointer hover:underline"
onClick={() => {
navigate(`/search?q=${encodeURIComponent(query)}`);
if (onClose) onClose();
}}
>
See all results
</div>
)}
</div>
</div>
)}
</div>
);
}
export default SearchBar;

View File

@ -0,0 +1,141 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import {
supabase,
signUp as supabaseSignUp,
signIn as supabaseSignIn,
signOut as supabaseSignOut,
getCurrentUser,
getUserProfile,
} from "../services/supabase";
const AuthContext = createContext();
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState(null);
const [userProfile, setUserProfile] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const signup = useCallback(async (email, password, username) => {
try {
setError(null);
const result = await supabaseSignUp(email, password, username);
return result;
} catch (error) {
const errorMessage = error.message || "Failed to sign up";
setError(errorMessage);
throw error;
}
}, []);
const login = useCallback(async (email, password) => {
try {
setError(null);
const result = await supabaseSignIn(email, password);
return result;
} catch (error) {
const errorMessage = error.message || "Failed to log in";
setError(errorMessage);
throw error;
}
}, []);
const logout = useCallback(async () => {
try {
setError(null);
await supabaseSignOut();
setCurrentUser(null);
setUserProfile(null);
} catch (error) {
const errorMessage = error.message || "Failed to log out";
setError(errorMessage);
throw error;
}
}, []);
const fetchUserProfile = useCallback(async (uid) => {
if (!uid) return null;
try {
setError(null);
return await getUserProfile(uid);
} catch (error) {
const errorMessage = error.message || "Failed to fetch user profile";
setError(errorMessage);
return null;
}
}, []);
useEffect(() => {
let authSubscription;
const initializeAuth = async () => {
try {
setError(null);
const user = await getCurrentUser();
if (user) {
setCurrentUser(user);
const profile = await fetchUserProfile(user.id);
setUserProfile(profile);
}
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
if (session?.user) {
setCurrentUser(session.user);
const profile = await fetchUserProfile(session.user.id);
setUserProfile(profile);
} else {
setCurrentUser(null);
setUserProfile(null);
}
});
authSubscription = subscription;
} catch (error) {
const errorMessage = error.message || "Failed to initialize auth";
setError(errorMessage);
} finally {
setLoading(false);
}
};
initializeAuth();
return () => {
if (authSubscription) {
authSubscription.unsubscribe();
}
};
}, [fetchUserProfile]);
const value = {
currentUser,
userProfile,
signup,
login,
logout,
loading,
error,
updateProfile: fetchUserProfile,
};
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
}

View File

@ -0,0 +1,117 @@
import { createContext, useContext, useState, useCallback } from "react";
import {
getTrendingMedia,
getMediaById,
searchMedia,
} from "../services/mediaService";
const MediaContext = createContext();
export function useMedia() {
const context = useContext(MediaContext);
if (!context) {
throw new Error("useMedia must be used within a MediaProvider");
}
return context;
}
export function MediaProvider({ children }) {
const [trendingMovies, setTrendingMovies] = useState([]);
const [trendingTvShows, setTrendingTvShows] = useState([]);
const [trendingGames, setTrendingGames] = useState([]);
const [currentMedia, setCurrentMedia] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [searchResults, setSearchResults] = useState([]);
const fetchTrendingMedia = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [movies, tvShows, games] = await Promise.all([
getTrendingMedia("movie"),
getTrendingMedia("tv"),
getTrendingMedia("game"),
]);
setTrendingMovies(movies || []);
setTrendingTvShows(tvShows || []);
setTrendingGames(games || []);
} catch (err) {
setError(err.message || "Failed to fetch trending media");
console.error("Fetch trending error:", err);
// Сброс состояний на случай частичного успеха
setTrendingMovies([]);
setTrendingTvShows([]);
setTrendingGames([]);
} finally {
setLoading(false);
}
}, []);
const fetchMediaDetails = useCallback(async (id) => {
if (!id) {
setError("Invalid media ID");
return;
}
setLoading(true);
setError(null);
setCurrentMedia(null);
try {
const data = await getMediaById(id);
if (!data) {
throw new Error("Media not found");
}
setCurrentMedia(data);
} catch (err) {
setError(err.message || "Failed to fetch media details");
console.error("Fetch details error:", err);
} finally {
setLoading(false);
}
}, []);
const handleSearch = useCallback(async (query) => {
if (!query || !query.trim()) {
setSearchResults([]);
return;
}
setLoading(true);
setError(null);
try {
const results = await searchMedia(query);
setSearchResults(Array.isArray(results) ? results : []);
} catch (err) {
setError(err.message || "Search failed");
console.error("Search error:", err);
setSearchResults([]);
} finally {
setLoading(false);
}
}, []);
const value = {
trendingMovies,
trendingTvShows,
trendingGames,
currentMedia,
loading,
error,
searchResults,
fetchTrendingMedia,
fetchMediaDetails,
handleSearch,
clearError: () => setError(null),
clearCurrentMedia: () => setCurrentMedia(null),
clearSearchResults: () => setSearchResults([]),
};
return (
<MediaContext.Provider value={value}>{children}</MediaContext.Provider>
);
}

92
src/index.css Normal file
View File

@ -0,0 +1,92 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-primary: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
--color-bg: #1A202C;
--color-text: rgba(255, 255, 255, 0.87);
--color-accent: #FF9D00;
}
@layer base {
html {
@apply scroll-smooth;
}
body {
@apply bg-campfire-dark text-campfire-light font-sans m-0 min-h-screen;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
@apply font-bold leading-tight mb-4;
}
h1 {
@apply text-3xl md:text-4xl;
}
h2 {
@apply text-2xl md:text-3xl;
}
h3 {
@apply text-xl md:text-2xl;
}
a {
@apply text-campfire-amber transition-colors duration-300;
}
a:hover {
@apply text-campfire-ember;
}
}
@layer components {
.container-custom {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
.btn {
@apply px-4 py-2 rounded-md font-medium transition-all duration-300 focus:outline-none;
}
.btn-primary {
@apply btn bg-campfire-amber text-campfire-dark hover:bg-campfire-ember;
}
.btn-secondary {
@apply btn border border-campfire-ash text-campfire-ash hover:bg-campfire-charcoal;
}
.card {
@apply bg-campfire-charcoal rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:scale-[1.02];
}
.input {
@apply bg-campfire-dark border border-campfire-ash rounded-md px-4 py-2 text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent outline-none transition duration-300;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-campfire-dark;
}
::-webkit-scrollbar-thumb {
@apply bg-campfire-ash rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-campfire-amber;
}

10
src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,213 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { createMedia } from "../services/supabase";
import { useAuth } from "../contexts/AuthContext";
function AdminMediaPage() {
const navigate = useNavigate();
const { currentUser, userProfile } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [mediaData, setMediaData] = useState({
title: "",
type: "movie",
poster_url: "",
backdrop_url: "",
overview: "",
release_date: "",
is_published: false,
});
// Check if user has admin/moderator privileges
if (
!userProfile?.role ||
!["admin", "moderator"].includes(userProfile.role)
) {
return (
<div className="pt-20 container-custom py-12">
<div className="bg-status-error bg-opacity-20 text-status-error p-6 rounded-lg">
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p>You don't have permission to access this page.</p>
</div>
</div>
);
}
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setMediaData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const newMedia = await createMedia({
...mediaData,
created_by: currentUser.id,
});
navigate(`/media/${newMedia.id}`);
} catch (err) {
setError("Failed to create media. Please try again.");
console.error("Error creating media:", err);
} finally {
setLoading(false);
}
};
return (
<div className="pt-20 container-custom py-12">
<h1 className="text-3xl font-bold mb-6">Create New Media</h1>
{error && (
<div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-md mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="max-w-2xl">
<div className="space-y-6">
<div>
<label className="block text-campfire-light mb-2" htmlFor="title">
Title *
</label>
<input
type="text"
id="title"
name="title"
value={mediaData.title}
onChange={handleInputChange}
className="input w-full"
required
/>
</div>
<div>
<label className="block text-campfire-light mb-2" htmlFor="type">
Type *
</label>
<select
id="type"
name="type"
value={mediaData.type}
onChange={handleInputChange}
className="input w-full"
required
>
<option value="movie">Movie</option>
<option value="tv">TV Show</option>
<option value="game">Game</option>
</select>
</div>
<div>
<label
className="block text-campfire-light mb-2"
htmlFor="poster_url"
>
Poster URL
</label>
<input
type="url"
id="poster_url"
name="poster_url"
value={mediaData.poster_url}
onChange={handleInputChange}
className="input w-full"
placeholder="https://example.com/poster.jpg"
/>
</div>
<div>
<label
className="block text-campfire-light mb-2"
htmlFor="backdrop_url"
>
Backdrop URL
</label>
<input
type="url"
id="backdrop_url"
name="backdrop_url"
value={mediaData.backdrop_url}
onChange={handleInputChange}
className="input w-full"
placeholder="https://example.com/backdrop.jpg"
/>
</div>
<div>
<label
className="block text-campfire-light mb-2"
htmlFor="overview"
>
Overview *
</label>
<textarea
id="overview"
name="overview"
value={mediaData.overview}
onChange={handleInputChange}
className="input w-full h-32"
required
/>
</div>
<div>
<label
className="block text-campfire-light mb-2"
htmlFor="release_date"
>
Release Date
</label>
<input
type="date"
id="release_date"
name="release_date"
value={mediaData.release_date}
onChange={handleInputChange}
className="input w-full"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="is_published"
name="is_published"
checked={mediaData.is_published}
onChange={handleInputChange}
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber"
/>
<label className="ml-2 text-campfire-light" htmlFor="is_published">
Publish immediately
</label>
</div>
<div className="flex justify-end gap-4">
<button
type="button"
onClick={() => navigate(-1)}
className="btn-secondary"
disabled={loading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Creating..." : "Create Media"}
</button>
</div>
</div>
</form>
</div>
);
}
export default AdminMediaPage;

188
src/pages/HomePage.jsx Normal file
View File

@ -0,0 +1,188 @@
import { useEffect } from "react";
import { Link } from "react-router-dom";
import { useMedia } from "../contexts/MediaContext";
import { useAuth } from "../contexts/AuthContext";
import MediaCarousel from "../components/media/MediaCarousel";
import { FiTrendingUp, FiCalendar, FiAward } from "react-icons/fi";
import { getImageUrl } from "../services/tmdbApi";
function HomePage() {
const { fetchTrendingMedia, trendingMovies, trendingTvShows, loading } =
useMedia();
const { currentUser } = useAuth();
useEffect(() => {
fetchTrendingMedia();
}, [fetchTrendingMedia]);
// Get featured media (first trending movie with backdrop)
const featuredMedia =
trendingMovies.find((movie) => movie.backdrop_path) || trendingMovies[0];
return (
<div className="pt-20">
{/* Hero Section */}
{featuredMedia && (
<div className="relative w-full h-[500px] md:h-[600px] overflow-hidden">
<div className="absolute inset-0">
<img
src={getImageUrl(featuredMedia.backdrop_path, "original")}
alt={featuredMedia.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark via-campfire-dark/60 to-transparent"></div>
</div>
<div className="container-custom relative h-full flex flex-col justify-end pb-16">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-campfire-amber text-campfire-dark mb-4">
<FiTrendingUp className="mr-1" /> Trending
</span>
<h1 className="text-4xl md:text-5xl font-bold mb-4 max-w-2xl">
{featuredMedia.title}
</h1>
<p className="text-campfire-ash mb-6 max-w-2xl">
{featuredMedia.overview.substring(0, 200)}...
</p>
<Link
to={`/media/${featuredMedia.id}?type=movie`}
className="btn-primary inline-flex items-center"
>
View Details
</Link>
</div>
</div>
)}
{/* Welcome Message for Logged In Users */}
{currentUser && (
<div className="container-custom my-8">
<div className="bg-campfire-charcoal rounded-lg p-6">
<h2 className="text-2xl font-bold mb-2">
С возвращением, {currentUser.username}!
</h2>
<p className="text-campfire-ash mb-4">
Продолжайте открывать отличный контент и делиться своими мыслями с
сообществом.
</p>
<Link
to={`/profile/${currentUser.uid}`}
className="text-campfire-amber hover:text-campfire-ember"
>
Перейти в профиль
</Link>
</div>
</div>
)}
{/* Main Content */}
<div className="container-custom py-8">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
) : (
<>
{/* Trending Movies */}
<div className="mb-12">
<div className="flex items-center mb-2">
<FiTrendingUp className="text-campfire-amber mr-2" size={20} />
<h2 className="text-2xl font-bold">Фильмы</h2>
</div>
<MediaCarousel
media={trendingMovies}
mediaType="movie"
seeAllLink="/discover/movies"
/>
</div>
{/* Trending TV Shows */}
<div className="mb-12">
<div className="flex items-center mb-2">
<FiTrendingUp className="text-campfire-amber mr-2" size={20} />
<h2 className="text-2xl font-bold">Сериалы</h2>
</div>
<MediaCarousel
media={trendingTvShows}
mediaType="tv"
seeAllLink="/discover/tv"
/>
</div>
{/* Latest Reviews */}
<div className="mb-12">
<div className="flex items-center mb-6">
<FiCalendar className="text-campfire-amber mr-2" size={20} />
<h2 className="text-2xl font-bold">Последние рецензии</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Placeholder for latest reviews */}
<div className="bg-campfire-charcoal rounded-lg p-6 flex flex-col items-center justify-center text-center h-48">
<p className="text-campfire-ash mb-4">Рецензий пока нет</p>
{!currentUser && (
<Link
to="/login"
className="text-campfire-amber hover:text-campfire-ember"
>
Войдите, чтобы написать рецензию
</Link>
)}
</div>
<div className="bg-campfire-charcoal rounded-lg p-6 flex flex-col items-center justify-center text-center h-48">
<p className="text-campfire-ash mb-4">Рецензий пока нет</p>
{!currentUser && (
<Link
to="/login"
className="text-campfire-amber hover:text-campfire-ember"
>
Войдите, чтобы написать рецензию
</Link>
)}
</div>
</div>
</div>
{/* Critic's Choice */}
<div className="mb-12">
<div className="flex items-center mb-6">
<FiAward className="text-campfire-amber mr-2" size={20} />
<h2 className="text-2xl font-bold">Выбор Резидентов</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{trendingMovies.slice(0, 3).map((movie) => (
<Link
key={movie.id}
to={`/media/${movie.id}?type=movie`}
className="block"
>
<div className="card h-full">
<div className="relative aspect-video">
<img
src={
getImageUrl(movie.backdrop_path, "w780") ||
getImageUrl(movie.poster_path, "w342")
}
alt={movie.title}
className="w-full h-full object-cover"
/>
</div>
<div className="p-4">
<h3 className="font-bold text-lg mb-2">
{movie.title}
</h3>
<p className="text-campfire-ash text-sm line-clamp-2">
{movie.overview}
</p>
</div>
</div>
</Link>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}
export default HomePage;

171
src/pages/LoginPage.jsx Normal file
View File

@ -0,0 +1,171 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
function LoginPage() {
const [formData, setFormData] = useState({
email: "",
password: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.email || !formData.password) {
setError("Заполните все поля");
return;
}
try {
setError("");
setLoading(true);
const { error } = await login(formData.email, formData.password);
if (error) throw error;
navigate("/", { replace: true });
} catch (err) {
console.error("Login error:", err);
setError(
err.message === "Invalid login credentials"
? "Неверный email или пароль"
: "Ошибка при входе. Попробуйте позже"
);
} finally {
setLoading(false);
}
};
return (
<div className="pt-20 min-h-screen flex items-center justify-center bg-campfire-dark">
<div className="container-custom max-w-md py-12">
<div className="bg-campfire-charcoal rounded-lg shadow-lg p-8 border border-campfire-ash/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-campfire-light mb-2">
Вход в CampFire
</h1>
<p className="text-campfire-ash">
Войдите, чтобы оценивать и рецензировать контент
</p>
</div>
{error && (
<div className="bg-status-error/20 text-status-error p-3 rounded-md mb-6 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-campfire-light mb-1"
>
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="input w-full"
placeholder="your@email.com"
required
autoComplete="email"
/>
</div>
<div>
<div className="flex justify-between items-center mb-1">
<label
htmlFor="password"
className="block text-sm font-medium text-campfire-light"
>
Пароль
</label>
<Link
to="/forgot-password"
className="text-xs text-campfire-amber hover:text-campfire-ember transition-colors"
>
Забыли пароль?
</Link>
</div>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className="input w-full"
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={loading}
className={`btn-primary w-full ${loading ? "opacity-80" : ""}`}
>
{loading ? (
<span className="inline-flex items-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Вход...
</span>
) : (
"Войти"
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-campfire-ash">
Нет аккаунта?{" "}
<Link
to="/register"
className="text-campfire-amber hover:text-campfire-ember font-medium transition-colors"
>
Зарегистрироваться
</Link>
</div>
</div>
</div>
</div>
);
}
export default LoginPage;

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

@ -0,0 +1,378 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useMedia } from '../contexts/MediaContext';
import { useAuth } from '../contexts/AuthContext';
import { FaStar, FaCalendar, FaClock, FaUser } from 'react-icons/fa';
import { getImageUrl } from '../services/tmdbApi';
import ReviewForm from '../components/reviews/ReviewForm';
import ReviewCard from '../components/reviews/ReviewCard';
import MediaCarousel from '../components/media/MediaCarousel';
function MediaPage() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const mediaType = searchParams.get('type') || 'movie';
const { fetchMediaDetails, currentMedia, loading } = useMedia();
const { currentUser } = useAuth();
const [activeTab, setActiveTab] = useState('overview');
const [reviews, setReviews] = useState([]);
useEffect(() => {
if (id && mediaType) {
fetchMediaDetails(id, mediaType);
window.scrollTo(0, 0);
}
}, [id, mediaType, fetchMediaDetails]);
// Mock submit review function
const handleSubmitReview = (reviewData) => {
// In a real app, this would send the review to the backend
const newReview = {
id: Date.now().toString(),
user: {
id: currentUser.uid,
username: currentUser.displayName || 'User',
profilePicture: currentUser.photoURL,
isCritic: false
},
...reviewData,
likes: 0,
comments: []
};
setReviews(prevReviews => [newReview, ...prevReviews]);
return Promise.resolve();
};
if (loading) {
return (
<div className="pt-20 flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
if (!currentMedia) {
return (
<div className="pt-20 container-custom py-16 text-center">
<h1 className="text-3xl font-bold mb-4">Media Not Found</h1>
<p className="text-campfire-ash">The requested media could not be found.</p>
</div>
);
}
// Extract media details
const {
backdrop_path,
poster_path,
title,
name,
overview,
vote_average,
release_date,
first_air_date,
runtime,
episode_run_time,
genres = [],
credits = { cast: [], crew: [] },
videos = { results: [] },
similar = { results: [] },
recommendations = { results: [] }
} = currentMedia;
const mediaTitle = title || name;
const releaseDate = release_date || first_air_date;
const duration = runtime || (episode_run_time && episode_run_time[0]);
const backdropUrl = getImageUrl(backdrop_path, 'original');
const posterUrl = getImageUrl(poster_path, 'w342');
// Format release date
const formattedDate = releaseDate ? new Date(releaseDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : 'Unknown';
// Format duration
const formattedDuration = duration ? `${Math.floor(duration / 60)}h ${duration % 60}m` : 'Unknown';
// Get trailer
const trailer = videos.results.find(video => video.type === 'Trailer') || videos.results[0];
return (
<div className="pt-16">
{/* Hero Section */}
<div className="relative w-full h-[500px] md:h-[600px]">
{backdropUrl ? (
<>
<div className="absolute inset-0">
<img
src={backdropUrl}
alt={mediaTitle}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-dark via-campfire-dark/70 to-transparent"></div>
</div>
</>
) : (
<div className="absolute inset-0 bg-campfire-charcoal"></div>
)}
<div className="container-custom relative h-full flex items-end pb-12">
<div className="flex flex-col md:flex-row items-center md:items-end gap-8">
{posterUrl && (
<div className="w-48 md:w-64 flex-shrink-0 rounded-lg overflow-hidden shadow-xl transform md:translate-y-16">
<img
src={posterUrl}
alt={mediaTitle}
className="w-full h-auto"
/>
</div>
)}
<div className="text-center md:text-left">
<h1 className="text-3xl md:text-5xl font-bold mb-2">{mediaTitle}</h1>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 mb-4 text-sm">
{releaseDate && (
<div className="flex items-center text-campfire-ash">
<FaCalendar className="mr-1" />
<span>{formattedDate}</span>
</div>
)}
{duration && (
<div className="flex items-center text-campfire-ash">
<FaClock className="mr-1" />
<span>{formattedDuration}</span>
</div>
)}
{vote_average > 0 && (
<div className="flex items-center text-campfire-amber">
<FaStar className="mr-1" />
<span>{(vote_average / 2).toFixed(1)}</span>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-6">
{genres.map(genre => (
<span
key={genre.id}
className="inline-block px-3 py-1 rounded-full text-xs font-medium bg-campfire-charcoal"
>
{genre.name}
</span>
))}
</div>
{trailer && (
<a
href={`https://www.youtube.com/watch?v=${trailer.key}`}
target="_blank"
rel="noopener noreferrer"
className="btn-primary inline-flex items-center"
>
Watch Trailer
</a>
)}
</div>
</div>
</div>
</div>
{/* Content Section */}
<div className="container-custom py-12 md:py-24">
{/* Tabs Navigation */}
<div className="border-b border-campfire-charcoal mb-8">
<div className="flex overflow-x-auto space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`pb-4 font-medium ${
activeTab === 'overview'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('reviews')}
className={`pb-4 font-medium ${
activeTab === 'reviews'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Reviews
</button>
<button
onClick={() => setActiveTab('similar')}
className={`pb-4 font-medium ${
activeTab === 'similar'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Similar
</button>
</div>
</div>
{/* Tab Content */}
<div className="mb-12">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div>
<h2 className="text-2xl font-bold mb-4">Synopsis</h2>
<p className="text-campfire-light mb-8">{overview}</p>
{/* Cast Section */}
{credits.cast && credits.cast.length > 0 && (
<div className="mb-8">
<h2 className="text-2xl font-bold mb-4">Cast</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{credits.cast.slice(0, 6).map(person => (
<div key={person.id} className="text-center">
<div className="relative w-full aspect-[2/3] mb-2 bg-campfire-charcoal rounded-lg overflow-hidden">
{person.profile_path ? (
<img
src={getImageUrl(person.profile_path, 'w185')}
alt={person.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FaUser className="text-campfire-ash" size={32} />
</div>
)}
</div>
<h3 className="font-medium text-sm">{person.name}</h3>
<p className="text-campfire-ash text-xs">{person.character}</p>
</div>
))}
</div>
</div>
)}
{/* Crew Section */}
{credits.crew && credits.crew.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4">Crew</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{credits.crew
.filter(person =>
['Director', 'Producer', 'Writer', 'Screenplay'].includes(person.job)
)
.slice(0, 6)
.map(person => (
<div key={`${person.id}-${person.job}`} className="flex items-center">
<div className="w-12 h-12 rounded-full overflow-hidden bg-campfire-charcoal mr-3 flex-shrink-0">
{person.profile_path ? (
<img
src={getImageUrl(person.profile_path, 'w185')}
alt={person.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FaUser className="text-campfire-ash" size={18} />
</div>
)}
</div>
<div>
<h3 className="font-medium">{person.name}</h3>
<p className="text-campfire-ash text-sm">{person.job}</p>
</div>
</div>
))
}
</div>
</div>
)}
</div>
)}
{/* Reviews Tab */}
{activeTab === 'reviews' && (
<div>
<h2 className="text-2xl font-bold mb-6">Reviews</h2>
{/* Submit Review Form */}
{currentUser ? (
<div className="mb-8">
<ReviewForm
mediaId={id}
mediaType={mediaType}
onSubmit={handleSubmitReview}
/>
</div>
) : (
<div className="bg-campfire-charcoal rounded-lg p-6 mb-8 text-center">
<p className="text-campfire-ash mb-4">Sign in to write a review</p>
<a href="/login" className="btn-primary">
Sign In
</a>
</div>
)}
{/* Reviews List */}
{reviews.length > 0 ? (
<div className="space-y-6">
{reviews.map(review => (
<ReviewCard key={review.id} review={review} isDetailed={true} />
))}
</div>
) : (
<div className="bg-campfire-charcoal rounded-lg p-8 text-center">
<p className="text-campfire-ash">No reviews yet. Be the first to review!</p>
</div>
)}
</div>
)}
{/* Similar Tab */}
{activeTab === 'similar' && (
<div>
{/* Similar Titles */}
{similar.results && similar.results.length > 0 && (
<div className="mb-12">
<h2 className="text-2xl font-bold mb-6">Similar Titles</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{similar.results.slice(0, 10).map(item => (
<MediaCard key={item.id} media={item} type={mediaType} />
))}
</div>
</div>
)}
{/* Recommendations */}
{recommendations.results && recommendations.results.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-6">Recommendations</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{recommendations.results.slice(0, 10).map(item => (
<MediaCard key={item.id} media={item} type={mediaType} />
))}
</div>
</div>
)}
{(!similar.results || similar.results.length === 0) &&
(!recommendations.results || recommendations.results.length === 0) && (
<div className="bg-campfire-charcoal rounded-lg p-8 text-center">
<p className="text-campfire-ash">No similar content available.</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default MediaPage;

View File

@ -0,0 +1,24 @@
import { Link } from "react-router-dom";
import { FiHome } from "react-icons/fi";
function NotFoundPage() {
return (
<div className="pt-20 flex items-center justify-center min-h-screen">
<div className="container-custom max-w-2xl text-center py-12">
<h1 className="text-6xl font-bold mb-6 text-campfire-amber">404</h1>
<h2 className="text-3xl font-bold mb-4">
Оказавшись в лимбе, вы не нашли подходящую страницу
</h2>
<p className="text-campfire-ash mb-8">
Похоже, вы сбились с пути. Страница, которую вы ищете, не существует
или была перемещена.
</p>
<Link to="/" className="btn-primary inline-flex items-center">
<FiHome className="mr-2" /> Вернуться
</Link>
</div>
</div>
);
}
export default NotFoundPage;

390
src/pages/ProfilePage.jsx Normal file
View File

@ -0,0 +1,390 @@
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import {
FiEdit,
FiSettings,
FiCalendar,
FiStar,
FiFilm,
FiTv,
FiLogOut,
} from "react-icons/fi";
import ReviewCard from "../components/reviews/ReviewCard";
import RatingChart from "../components/reviews/RatingChart";
function ProfilePage() {
const { id } = useParams();
const { currentUser, userProfile, logout } = useAuth();
const [isCurrentUser, setIsCurrentUser] = useState(false);
const [userData, setUserData] = useState(null);
const [activeTab, setActiveTab] = useState("reviews");
const [userReviews, setUserReviews] = useState([]);
// Check if this is the current user's profile
useEffect(() => {
if (currentUser && id === currentUser.uid) {
setIsCurrentUser(true);
setUserData(userProfile);
} else {
setIsCurrentUser(false);
// In a real app, fetch the user data for the profile being viewed
// For now, we'll use mock data
setUserData({
username: "Degradin",
email: "degradin@campfiregg.ru",
bio: "CEO of CampFireGG.",
profilePicture:
"https://staff.campfiregg.ru/assets/avatars/gdVPZMyCy9StMDMy.gif",
createdAt: "2025-01-15T00:00:00.000Z",
reviewCount: 28,
isCritic: true,
});
}
}, [currentUser, id, userProfile]);
// Mock loading reviews
useEffect(() => {
// In a real app, fetch user reviews from the database
// For now, we'll use mock data
const mockReviews = [
{
id: "1",
user: {
id: id,
username: userData?.username || "User",
profilePicture: userData?.profilePicture,
isCritic: userData?.isCritic || false,
},
mediaId: "123",
mediaType: "movie",
mediaTitle: "The Last of Us",
mediaPoster:
"https://kinopoisk-ru.clstorage.net/1L67Lp105/810449redVB/ZukoaopfTiUSOnVYJJxkXkBCJLdbT63V4ifXLUdXiOvBUJT00q4PPtjWH9FFjkFNvMIv_JU9XtLv_pkyuD8UF7Osabtd2tgwV_qmziZcZBlH1_OkCM6_RCVxE3vRx4TmTdoPOVoixLZBSc4BVEkVmqkSP-pDL4lBKztFqCiuHg_kIK4mS4GJ5xwqOBon0e1e1gilv4NDYd31K1FnDBKGrwXfBPPQrMTxiUnJxRVQ35uk2bHhSKOA2SkyA6c1qldGKiFr5UNOhmBbq7FZZhsh21mK4f_MBidRt2GTY4lYAeADGg__U2RIfA5HT06X0JMZZ9E4qUsuAxrjvUNl9W8XQWfsoaTOQsfvVig_0mQXppyZxeB5hIsp1fyunOVB2kGkiJtCdVygC3kBhMdEFJ7flCgTN62K5UCR7vbBLzauFAArrmnvA4pGr1NtuN3j0CbanUzttwTHrdwzLVQqzZ8CZ4VWx_MZp8z1QoQATtVfWhQn2P9pQ29JE2Q3Bq92rpjK7-Eh6k1NTmWca3edbdQuWV9DaLfNz6Gbsukd70GewuJGEU333-IDeocHSIARUxFd4dizKopngh5rfMChdGiZwmempO9MActs1yp_UaeV5FIWAS76joOh1HPo3m5G1wjiBlzK8BUshzvPS05HlJpa0eOQeSoK64EbpfgAYvBnV8Ev4Oquxs4O6JSkPVPlUS8YmcNv9MPHJ5a4JpAkTVqKaIDQhrDRqov8QAeGDdAYlNnrWvhgQioC3mG3gu96IVtOpuVipcrODeGSrfhcr9mhHdEIIH5IyCWU9aOQY4URy62ImQ021-bCdE-CDohcUZTVYZP3LYwuxp-puAYq--FQjyokoONCAkvg3ak3maWcrN1ej-F5QYwuFHtl0KRPn8JnABuM-NssDr0JiYHJmJ8bkuhc-yiAoU9R6bBHYXaq1wnk76UsxIKIIh1gupDm0WVYlkymfA3LJxx07VgvytUOKksYzbvS7Mu6AQdFj1pYFh3oETglROdNnC53weF97BhMou2rbc9HCO-Tbb6T4pylkV1NoX5Nx2EdOySUIkiTBuqOmo38EuyMPEtCCsASV92Vq5v4L81mCBenc0bq-2ZcAm3kauyHQkZkVesw0KPeKxSTQGEyxcfgUrihFCUBG0Ogzl9FNBxrjPWDz0AO0BaTESYUNaHBIIvW7zuB4nnmXcjiYaGvTw7Bb1zvd5nun-ReWw3v84OHJ5gxq52lDthC6cAfibAQJ4e7h4bIBBEYUpoh0j6lymuLESq_xCV6LFGO5CKtrQbICGWc6XAbrh_mkNOAI3wOi-9Zf-EdrQDdj-rM3of6FWUAd08AT42ZENXQb9A4LAchz5fksAPju2ifjiNhpKKPSoCmkGew0qKbIVIai2vyAMxuWTDl0OYNVcvngJqO8h1kBbcAhEZAk1AeWetYOuAFJcoQY3yI6vxmWEXqp2Orz4iGqRwg_xrtFiCSmwDvt0yLJ13_LFnjhBFL6Ezfgr5XbY-1QAAKAJjbnN2u2j1sCi1N22t1gi00KBSI6K_r54zFgCRTqzEdpNBvnBlFoTlLgmwZdO8cJY8Xz2OMEUp-UK3KvE-Jx0YUV90c4hrzbMVlD9_u9AtveGGXD62s62eAAovqlq0w3GoYrpJQhCezhYym0r9pEOIFGIrqhFMHdVMtRbHHAcGI3xveXqtVPi_K5sbSIbOBJPgrWQ3s5-5qAIEG7hJsfZTuECdWGork-QqLKVOxqVsrwJ9FpEXXBj_dqET_yQnHghPTndIs0vKjDGoP3KE9zy90YZ_OrK8pKk0Njy-VbPCWLBtt21mPLrHBBO2QfaTWqwTbT2rIW4s1F20DdsvOxk_VWBQTIxC3owfjxt_lO0_psehYwWUkoedCCMZkVq-7lukVINZbDWA4i88iGn8g3mhNVcDtiJPNtFwrCzGAgwZN2NeSkKObOeoKbwqfJjUDLzokEY9jIWFtDwcI4pqgehmq2OMZnglvdMwNrpl_KFxjhBqCIgxew3rYYAwzwAmHzFIaV5QnG7HjjapJ1uS3iyLzYxXAp6UpbwZGjeGfYrle7RHhUpMHrrkEBaadMuQQJALfwaJAXkzzl63DuspBi0xbERecK5j2pAMhQt5j9YdtsaeWxCgmLWuHSUmhWqD1WaOZ4ViTQmY6BY1q2vtgnmUMWYZigNfG8JekRbkOC0JKk5zZWuHVcyMPI8cW7_cKInAkFsmmZGzti05L6B6icVAsmWEQGwtgeQxNodNxIBfmTNdBZUhYBb4bIkt9zghOhtoblBKjGvOjzaOKUe_7z6O-px8G7-RuLsAAh2md4XpVoZek3FkHrTcGgOaReuxZp83YQWCEF8I822uE-8eEyIiYmVEaL1-6aMmpxxmkMoBvNWNcj-egpWCEh8CpkOQ52SpdrZZSCuOyjAuvkvXsFOwNGoOshpvJPFluA75GAEMAX59cnanc_eQEK4HeoncJ5LDuHgXjZ6WnBMxLbJBhMljpl25U30VnOcLLr1p9LhjsydgPbc7XDnOdpI-7CQvCjZzY2thuUPrlwiKD0a06Cu9yLFaPLGTr5EiFxOFYonuUqR9hGV5Ka3TER2GeuSDWLQnVwWjBEQxzkahLt06Pw4VUURebKBf7K4QqyZus-0RsNibfCeAuIirCBggpWiu-HCvR4RxVSGC3hoBp2_ghmCKC2YGqhlBH9l3uwvbOzgbFkBgbGy9Sfa3JJ4kZJLeHqvHrE4lrbi3vCwiP7VeruBKhVSaeFo0ldk2A79ox59lnjRHHYs_aib_a78j7gkwOhR0Z1B1q0bikgCHBGSP1QiK7aBTHaOxsqAVMgS4VrLETItGsHJWKZz7MgiGbN-XRrcTVh6vN1kq_GmtEPIuIRkQbEZ_dYFSyIkctzhFhtUzt_aHUAudlparHD0vtH2o6FuTTJ93QSyi-ScytW_KtUOTJ2gesBtvCfdqrA7cJQY-B1Jzd0mjYfukN6sAaa0",
content: "Клубничка имба.",
ratings: {
story: 9,
visuals: 10,
performance: 8,
soundtrack: 10,
enjoyment: 9,
},
likes: 42,
comments: [],
createdAt: "2025-05-20T14:30:00.000Z",
spoiler: false,
},
{
id: "2",
user: {
id: id,
username: userData?.username || "User",
profilePicture: userData?.profilePicture,
isCritic: userData?.isCritic || false,
},
mediaId: "456",
mediaType: "tv",
mediaTitle: "Breaking Bad",
mediaPoster:
"https://kinopoisk-ru.clstorage.net/1L67Lp105/810449redVB/ZukoaopfTiUSOnVYJJxkXkBCJLdbT63V4ifXLUdXiOvBUJT00q4PPtjWHZCFj0FO-VB7fVQ-X8cuLV6nbKsGUeY6fb1ODBjxRakyjfFJ8dnFgPEm3t24ROVjAHuQnEThzdrIfV1kBzXAh44BQhhVG2lV-CvFqZgWKvHFt_yhHgZqKiBrA8hObtUi8VirlSOdmsHoOYWDIZS_IRymRZ4NqcjRAvfTZED9wIBKQFvSVBai2_MgSaAAFCO3yqr46FfIa-_jY8zHySlUq7IZYVmmEhiFqHbAxa3W9eMYIw8ZQq8N2g50G-jNes4GwcLbntbVqlK7I8Qjw96s9whqPahQhaYlou7AhkYk1yU5G23e4dJXhC4-TcspnTrsnuuNWQYkjJeEe9-ryHUNjstBldJbmmhdv-IPYEjT53VCqfyrEcnk7ekjCAcGIZKiNpyqXqfW3ongvgFPIFK5JFFnyBrFKUESAj7YbEQ2DUnBgFRX25WjlXsrhWnKWSaywun8J9sNq6Xsbc8PzO6UbXYSrlYkFNEM4HcJQ2fTfW2U68naQOoGmAb4naPL844BD0RYHJJTo9k44o2ix1ZtdMzrOibThewg6WyDisWl1WW62O-cL12TSq00RUMoEnyuFeyAEECqSx8BMlMnirvCB8jBkVceUesTOGIBocKY6bQPbzXo0M2qbqjmTE9B5lsnuJorV2XSUQujeQHFatmy4F_nDZLKLMdTQfbVK000js-ChROZFlkhknIqwCIHXKY2guVz5F9Er-aj6kxMSOBS4rfQo9lgmppEKfIOx2iUN-AYL0bTDySO2MfwmWLFOwtHBwlamRrT6J0_5MTmRpSpskdks--eyG4k5WKACkmvVi1y2ubUoBUdhS77SszoGvGg3-XI2sWsDFkJ_FOuDH0AyEbNlBZdWieROuhLb4jUofMM6fhoGAalISrkAMmLb5-ge5mrHSXd3sIrtw3F4BV5KFRgghaI5w8YSvWTawt1jgwAD1Mb0lpqUjcsDW1FE2Z1jOK-p11Apy9r78VJRuGSpHid5t3sFt_FKfcBg-FasORZ4sQeSCKNWA01kyyNP8tDT0dc0FoZ4JI9pYwozppi8odkNOMTj67u6qfAB80nFCo73KRTadjSBWV_AQKu2TVkXqtMWwJiSJDFOxQsRPdGCYGFHZ7elG9fs6nKqw_SKjWP5rTi2QArJalvjIDGLhBtcpHlnCMQn4ukOUHFZFA_7d6kj1pLbAhcQTdYJwrzD4GLRBNfVZOpVLeigavIF65wSOV-7p8GaCmrJkpJzO4WavDRZZ7tmBJHK7RNDaUedW3WqoqXSGDJUgsyGqDGO4kGAswb2B_dq1I-b8vvTtmht44kOiCfwSsgpK_IwQ_imKo53eFZL1EZD6W6CoylUXGgnacC00UsjVsDOhulBnQND8_GWxOWWSNQ8m3P6slebTyHYzTnVAjt563vCscAbt_l8ZJsWO_Qkovg9k3FoZ64KZguRlNK4MhXT3ASLwQ0iUOPzdCREhyhV35ix20CVmQ2QKt6q5kK5W_hrEfBjSFUK_bbqhfhUtfFbvFEjuUVe2xeJUDXwSAGn4930moNOwCOyUFc0NNQYZl-rY8vBtPlvwLnMygeT-ZvYaCAykPkUio3FWLW7xsWQ-Q_SkQu3v1gma9PkkgoRNKEdFLlALOIiAeKENORGS5UPaIM5gscojVJZ3nmHA6tamwgA0dHYJNnf5FqXytRGICusE3Lr9A9K1BqyF0G6cDTzvrX5E69gI4NRtiQHZ6pmLFkgC8FnCx7QuszIN9O5a0sbY_OhueT6n1TYRWmEh1K5nvCD2wcMKbQroxXyGRMXsQwEqPHv0ePwIBTGdyRa92xbwnmBtgq-4QuuufQh24l4WKKh80kUKF9lm9YqxCfBG8xCcDmHrSuE-cC2E8khBhFe1SrgPQKT8KN3J9fEeBT-GKFKkYbJLdCpXaunoFr5WsvhUlL6F9g8tWim2TVmwsjdstMZR68LBguTZqAoEkWi_8frIK0gM5DBxFaW5Vg2_HlQGkP2aY_T2wxqtFF761pJsTMSO2do7WSa5kv2JXK7r7DRGFTcGBfqIjZAOxJmQKw0mMLvsjCww4aGlOZ45y2a8tiB17kMwAu9SnVymypbafLCAgoX--y3OOZJdjQAm2_S4gmmvTuHqYOnsAswBMBsNvlCHqCC8XGl9SVU64ZMWfJ58_S5r5P73ap2EQu6OurzApBbF1ru1PjGW1QmQQutotDLxC0Z53mgFnH5E_QTzxd68y6gQcJjxCZ3RFhmbGlSaqI0up7ziH1oBcNruoo4ILGwO8eYL7e7dyhEpXJYLxGBG0beCncZ49ZwigAF838FCRKsw2BB82SXNWdJNB6oUPnwJkjNAKqMeOeBeohZqQFgQDiGyMyVSfV6xmYh-U2zU1ulHhkl6dNmw4qjBzNfhGjDzKJCo8KlFFSG6eX9mzBoQefZr2JL7yhFAEtIaEkTgrF4p4os5btFimU1wNueA1Nphy6aJdjjxfPYsDbgrrbLwp9gosCydPXF9wrkPeqyKMIkCu-gu1-6Z7OLm_iaAeFSCpdYX_WZRlkFdgPI36Bg2LYtKZWo4LZym0G2YK21-sGOgaKCgFaGlSabJE57MDpQpHq8AGpdGAYAmSmLOKESYAo1KT3VKuZYR7aBOA8RosnmbXoWSiOmQgqR5IHepFiR7pHT0rFExbUnSkXv6HNqcAZpjPHbrmsmIkkqekris5EJVSi-d4vXuNdH0Eh90YNJlBzqRwnRt_AY81cTv2QaEr2xUcKSBLZ0tiq0rboy-HAHuT2TyQ6q9aKpuiuJc7Ah2dTq_hdq9Rh3hgDaXZEw2dWcaHWboKfCWHBn049FOSN_wEPy04akhLSL9gwL8fuyFyk-IBi82sTBS8hrOeNCkRtlSD9m6lfoJvZTOnzCk-nkzkgn2ONHw6qzBeM_dSjBn3Ixg6Bl9Ad2qMU-2UA4MNWY8",
content:
"Одно из величайших телевизионных шоу, когда-либо созданных. Развитие персонажа Уолтера Уайта не имеет себе равных, показывая его превращение из мягкосердечного учителя химии в безжалостного наркобарона. Брайан Крэнстон демонстрирует потрясающую игру, которую поддерживает не менее впечатляющий актерский состав. Сценарий неизменно превосходен, с плотным сюжетом и значимыми дугами персонажей.",
ratings: {
story: 10,
visuals: 8,
performance: 10,
soundtrack: 7,
enjoyment: 10,
},
likes: 87,
comments: [{ id: "c1", user: "TVFan", content: "Completely agree!" }],
createdAt: "2025-06-12T09:15:00.000Z",
spoiler: true,
},
];
setUserReviews(mockReviews);
}, [id, userData]);
if (!userData) {
return (
<div className="pt-20 flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
}
// Calculate average ratings from reviews
const calculateAverageRatings = () => {
if (!userReviews.length) return null;
const totals = {
story: 0,
visuals: 0,
performance: 0,
soundtrack: 0,
enjoyment: 0,
};
userReviews.forEach((review) => {
Object.keys(totals).forEach((key) => {
totals[key] += review.ratings[key];
});
});
const averages = {};
Object.keys(totals).forEach((key) => {
averages[key] = totals[key] / userReviews.length;
});
return averages;
};
const averageRatings = calculateAverageRatings();
// Format join date
const joinDate = new Date(userData.createdAt).toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="pt-20">
{/* Profile Header */}
<div className="bg-campfire-charcoal py-8">
<div className="container-custom">
<div className="flex flex-col md:flex-row items-center md:items-start gap-8">
{/* Profile Picture */}
<div className="relative">
<div className="w-32 h-32 rounded-full overflow-hidden bg-campfire-dark">
{userData.profilePicture ? (
<img
src={userData.profilePicture}
alt={userData.username}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-campfire-ash">
{userData.username.substring(0, 1).toUpperCase()}
</div>
)}
</div>
{isCurrentUser && (
<button className="absolute bottom-0 right-0 bg-campfire-amber text-campfire-dark p-2 rounded-full">
<FiEdit size={16} />
</button>
)}
</div>
{/* Profile Info */}
<div className="flex-1 text-center md:text-left">
<div className="flex flex-col md:flex-row md:items-center gap-4">
<h1 className="text-3xl font-bold">
{userData.username}
{userData.isCritic && (
<span className="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-campfire-amber text-campfire-dark rounded-full">
Резидент
</span>
)}
</h1>
{isCurrentUser && (
<div className="flex gap-2">
<Link to="/settings" className="btn-secondary text-sm">
<FiSettings className="mr-1" /> Настройки
</Link>
<button onClick={logout} className="btn-secondary text-sm">
<FiLogOut className="mr-1" /> Выйти
</button>
</div>
)}
</div>
<p className="text-campfire-ash my-4">
{userData.bio ||
`${userData.username} еще не написал ничего о себе.}`}
</p>
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-campfire-ash">
<div className="flex items-center">
<FiCalendar className="mr-1" />
<span>Участник с {joinDate}</span>
</div>
<div className="flex items-center">
<FiStar className="mr-1" />
<span>{userData.reviewCount} Рецензий</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Tabs Navigation */}
<div className="bg-campfire-dark">
<div className="container-custom">
<div className="flex overflow-x-auto space-x-8">
<button
onClick={() => setActiveTab("reviews")}
className={`py-4 font-medium ${
activeTab === "reviews"
? "text-campfire-amber border-b-2 border-campfire-amber"
: "text-campfire-ash hover:text-campfire-light"
}`}
>
Рецензии
</button>
<button
onClick={() => setActiveTab("stats")}
className={`py-4 font-medium ${
activeTab === "stats"
? "text-campfire-amber border-b-2 border-campfire-amber"
: "text-campfire-ash hover:text-campfire-light"
}`}
>
Статистика
</button>
<button
onClick={() => setActiveTab("favorites")}
className={`py-4 font-medium ${
activeTab === "favorites"
? "text-campfire-amber border-b-2 border-campfire-amber"
: "text-campfire-ash hover:text-campfire-light"
}`}
>
Избранное
</button>
</div>
</div>
</div>
{/* Tab Content */}
<div className="container-custom py-8">
{/* Reviews Tab */}
{activeTab === "reviews" && (
<div>
<h2 className="text-2xl font-bold mb-6">
Рецензии{" "}
<span className="text-campfire-ash">({userReviews.length})</span>
</h2>
{userReviews.length > 0 ? (
<div className="space-y-8">
{userReviews.map((review) => (
<div key={review.id} className="mb-6">
<div className="mb-2 flex items-center">
<div className="w-8 h-12 mr-2">
{review.mediaPoster ? (
<img
src={review.mediaPoster}
alt={review.mediaTitle}
className="w-full h-full object-cover rounded"
/>
) : (
<div className="w-full h-full bg-campfire-charcoal rounded"></div>
)}
</div>
<Link
to={`/media/${review.mediaId}?type=${review.mediaType}`}
className="text-campfire-amber hover:text-campfire-ember"
>
{review.mediaTitle}
</Link>
<span className="mx-2 text-campfire-ash"></span>
<span className="text-campfire-ash">
{review.mediaType === "movie" ? (
<FiFilm className="inline" />
) : (
<FiTv className="inline" />
)}
</span>
</div>
<ReviewCard review={review} isDetailed={true} />
</div>
))}
</div>
) : (
<div className="bg-campfire-charcoal rounded-lg p-8 text-center">
<p className="text-campfire-ash">No reviews yet.</p>
</div>
)}
</div>
)}
{/* Stats Tab */}
{activeTab === "stats" && (
<div>
<h2 className="text-2xl font-bold mb-6">Статистика оценок</h2>
{averageRatings ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="bg-campfire-charcoal rounded-lg p-6">
<h3 className="text-xl font-bold mb-4 text-center">
Средние оценки
</h3>
<RatingChart ratings={averageRatings} showLegend={true} />
</div>
<div className="bg-campfire-charcoal rounded-lg p-6">
<h3 className="text-xl font-bold mb-4">Разбивка оценок</h3>
<div className="space-y-4">
{Object.entries(averageRatings).map(([category, value]) => (
<div key={category}>
<div className="flex justify-between items-center mb-1">
<span className="capitalize">{category}</span>
<span className="font-medium">
{value.toFixed(1)}/10
</span>
</div>
<div className="w-full h-2 bg-campfire-dark rounded-full">
<div
className="h-full bg-campfire-amber rounded-full"
style={{ width: `${(value / 10) * 100}%` }}
></div>
</div>
</div>
))}
</div>
</div>
<div className="bg-campfire-charcoal rounded-lg p-6 md:col-span-2">
<h3 className="text-xl font-bold mb-4">Активность</h3>
<div className="flex justify-center items-center h-48 text-campfire-ash">
График активности скоро появится...
</div>
</div>
</div>
) : (
<div className="bg-campfire-charcoal rounded-lg p-8 text-center">
<p className="text-campfire-ash">
Статистика недоступна. Пишите рецензии, чтобы увидеть свою
статистику.
</p>
</div>
)}
</div>
)}
{/* Favorites Tab */}
{activeTab === "favorites" && (
<div>
<h2 className="text-2xl font-bold mb-6">Favorites</h2>
<div className="bg-campfire-charcoal rounded-lg p-8 text-center">
<p className="text-campfire-ash">
{isCurrentUser
? "Вы еще не добавили ни одного избранного. Отметьте медиа как избранное, чтобы видеть их здесь."
: `${userData.username} еще не добавил ничего в избранное.`}
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default ProfilePage;

164
src/pages/RegisterPage.jsx Normal file
View File

@ -0,0 +1,164 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
function RegisterPage() {
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { signup } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
if (!email || !username || !password || !confirmPassword) {
setError("Заполни все поля");
return;
}
if (password !== confirmPassword) {
setError("Хмм, пароли не совпадают...");
return;
}
if (password.length < 6) {
setError("Коротковато...");
return;
}
try {
setError("");
setLoading(true);
await signup(email, password, username);
navigate("/");
} catch (err) {
setError(
"Дружище, ты уже существуешь, либо кто-то другой зарегистрирован с такой же почтой."
);
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div className="pt-20 min-h-screen flex items-center justify-center">
<div className="container-custom max-w-md py-12">
<div className="bg-campfire-charcoal rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold mb-6 text-center">
Присоединиться к CampFire Critics
</h1>
<p className="text-campfire-ash mb-8 text-center">
Создай свою учетную запись CampFire, чтобы оценивать и рецензировать
все что движется.
</p>
{error && (
<div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-md mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label htmlFor="email" className="block text-campfire-light mb-2">
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full"
placeholder="your@campfiregg.ru"
required
/>
</div>
<div className="mb-6">
<label
htmlFor="username"
className="block text-campfire-light mb-2"
>
Имя пользователя
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input w-full"
placeholder="Say. Your. Name"
required
/>
</div>
<div className="mb-6">
<label
htmlFor="password"
className="block text-campfire-light mb-2"
>
Пароль
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full"
placeholder="••••••••"
required
/>
<p className="text-xs text-campfire-ash mt-1">
Не меньше 6 знаков
</p>
</div>
<div className="mb-8">
<label
htmlFor="confirm-password"
className="block text-campfire-light mb-2"
>
Пароль пароль
</label>
<input
type="password"
id="confirm-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="input w-full"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full"
>
{loading ? "Запечатываем..." : "Создать"}
</button>
</form>
<div className="mt-6 text-center">
<span className="text-campfire-ash">Уже в строю?</span>{" "}
<Link
to="/login"
className="text-campfire-amber hover:text-campfire-ember"
>
Войти
</Link>
</div>
</div>
</div>
</div>
);
}
export default RegisterPage;

1
src/services/firebase.js Normal file
View File

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

View File

@ -0,0 +1,131 @@
import axios from 'axios';
const IMDB_API_URL = 'https://imdb.iamidiotareyoutoo.com';
export const mediaTypes = {
MOVIE: 'movie',
TV: 'tv',
ALL: 'all' // Добавлено
};
function isLocalStorageAvailable() {
try {
const testKey = '__test__';
localStorage.setItem(testKey, testKey);
localStorage.removeItem(testKey);
return true;
} catch (e) {
return false;
}
}
async function searchIMDb(query, type = mediaTypes.ALL) {
try {
const response = await axios.get(`${IMDB_API_URL}/search`, {
params: { q: query },
timeout: 5000 // Добавлен таймаут
});
if (!response.data || !Array.isArray(response.data)) {
return [];
}
return response.data
.filter(item => {
if (!item) return false;
return type === mediaTypes.ALL ||
(item.type && item.type.toLowerCase() === type);
})
.map(item => ({
id: item.imdbID || '',
title: item.title || 'Unknown',
poster: item.poster || '',
rating: Math.min(100, Math.max(0, parseFloat(item.rating || '0') * 10)),
releaseDate: item.year || 'Unknown',
type: (item.type || '').toLowerCase()
}));
} catch (error) {
console.error('Error searching IMDb:', error);
return [];
}
}
export async function getTrendingMedia(type) {
try {
if (!isLocalStorageAvailable()) return [];
const mediaStr = localStorage.getItem('media');
if (!mediaStr) return [];
const media = JSON.parse(mediaStr);
if (!Array.isArray(media)) return [];
return media
.filter(item => item && item.type === type && item.trending === true)
.slice(0, 10);
} catch (error) {
console.error('Error fetching trending media:', error);
return [];
}
}
export async function getMediaById(id) {
try {
if (!isLocalStorageAvailable()) return null;
const mediaStr = localStorage.getItem('media');
if (!mediaStr) return null;
const media = JSON.parse(mediaStr);
if (!Array.isArray(media)) return null;
return media.find(item => item && item.id === id) || null;
} catch (error) {
console.error('Error fetching media:', error);
return null;
}
}
export async function searchMedia(query) {
try {
if (!query || !query.trim()) return [];
return await searchIMDb(query, mediaTypes.ALL);
} catch (error) {
console.error('Error searching media:', error);
return [];
}
}
export async function addMedia(mediaData) {
try {
if (!isLocalStorageAvailable()) {
throw new Error('Local storage is not available');
}
if (!mediaData || typeof mediaData !== 'object') {
throw new Error('Invalid media data');
}
const mediaStr = localStorage.getItem('media') || '[]';
const media = JSON.parse(mediaStr);
if (!Array.isArray(media)) {
throw new Error('Invalid media data in storage');
}
const newMedia = {
...mediaData,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
trending: Boolean(mediaData.trending),
rating: Math.min(100, Math.max(0, Number(mediaData.rating) || 0)),
ratingCount: Math.max(0, Number(mediaData.ratingCount) || 0)
};
media.push(newMedia);
localStorage.setItem('media', JSON.stringify(media));
return newMedia.id;
} catch (error) {
console.error('Error adding media:', error);
throw error;
}
}

138
src/services/supabase.js Normal file
View File

@ -0,0 +1,138 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables');
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Auth functions
export const signUp = async (email, password, username) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { username }
}
});
if (error) throw error;
// Создаем профиль в таблице users
const { error: profileError } = await supabase
.from('users')
.upsert({
id: data.user.id,
email,
username,
role: 'user'
});
if (profileError) throw profileError;
return data;
};
export const signIn = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw error;
return data;
};
export const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
};
export const getCurrentUser = async () => {
const { data: { user }, error } = await supabase.auth.getUser();
if (error) throw error;
return user;
};
// User functions
export const getUserProfile = async (userId) => {
const { data, error } = await supabase
.from('users')
.select('*')
.eq('id', userId)
.single();
if (error) throw error;
return data;
};
// Media functions
export const createMedia = async (mediaData) => {
const { data, error } = await supabase
.from('media')
.insert(mediaData)
.select()
.single();
if (error) throw error;
return data;
};
export const getMediaById = async (id) => {
const { data, error } = await supabase
.from('media')
.select(`
*,
created_by:users(username),
reviews(
*,
user:users(username, profile_picture, is_critic)
)
`)
.eq('id', id)
.single();
if (error) throw error;
return data;
};
export const listMedia = async (type = null, page = 1, limit = 10) => {
let query = supabase
.from('media')
.select('*')
.eq('is_published', true)
.order('created_at', { ascending: false });
if (type) query = query.eq('type', type);
const { data, error } = await query.range(
(page - 1) * limit,
page * limit - 1
);
if (error) throw error;
return data;
};
// Review functions
export const createReview = async (reviewData) => {
const { data, error } = await supabase
.from('reviews')
.insert(reviewData)
.select()
.single();
if (error) throw error;
return data;
};
export const getUserReviews = async (userId) => {
const { data, error } = await supabase
.from('reviews')
.select(`
*,
media(title, type, poster_url)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
};

68
src/services/tmdbApi.js Normal file
View File

@ -0,0 +1,68 @@
const TMDB_API_KEY = import.meta.env.VITE_TMDB_API_KEY;
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
/**
* Gets the full URL for a TMDB image
* @param {string} path - The image path from TMDB
* @param {string} size - The desired image size (w92, w154, w185, w342, w500, w780, original)
* @returns {string|null} The complete image URL or null if no path provided
*/
export const getImageUrl = (path, size = 'original') => {
if (!path) return null;
return `${TMDB_IMAGE_BASE_URL}/${size}${path}`;
};
/**
* Fetches media details from TMDB API
* @param {string} id - The media ID
* @param {string} type - The media type (movie or tv)
* @returns {Promise<Object>} The media details
*/
export const fetchMediaDetails = async (id, type) => {
const response = await fetch(
`${TMDB_BASE_URL}/${type}/${id}?api_key=${TMDB_API_KEY}&append_to_response=credits,videos,similar,recommendations`
);
if (!response.ok) {
throw new Error(`Failed to fetch ${type} details`);
}
return response.json();
};
/**
* Searches for media in TMDB
* @param {string} query - The search query
* @param {string} type - The media type (movie or tv)
* @returns {Promise<Object>} The search results
*/
export const searchMedia = async (query, type = 'movie') => {
const response = await fetch(
`${TMDB_BASE_URL}/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error('Failed to search media');
}
return response.json();
};
/**
* Fetches trending media from TMDB
* @param {string} type - The media type (movie or tv)
* @param {string} timeWindow - Time window (day or week)
* @returns {Promise<Object>} The trending media
*/
export const getTrending = async (type = 'movie', timeWindow = 'week') => {
const response = await fetch(
`${TMDB_BASE_URL}/trending/${type}/${timeWindow}?api_key=${TMDB_API_KEY}`
);
if (!response.ok) {
throw new Error('Failed to fetch trending media');
}
return response.json();
};

131
supabase/migrations/old.sql Normal file
View File

@ -0,0 +1,131 @@
/*
# Initial schema setup for CampFire Critics
1. New Tables
- `users`
- `id` (uuid, primary key)
- `email` (text, unique)
- `username` (text, unique)
- `role` (text)
- `created_at` (timestamp)
- `profile_picture` (text)
- `bio` (text)
- `is_critic` (boolean)
- `media`
- `id` (uuid, primary key)
- `title` (text)
- `type` (text)
- `poster_url` (text)
- `backdrop_url` (text)
- `overview` (text)
- `release_date` (date)
- `created_at` (timestamp)
- `created_by` (uuid, references users)
- `is_published` (boolean)
- `reviews`
- `id` (uuid, primary key)
- `user_id` (uuid, references users)
- `media_id` (uuid, references media)
- `content` (text)
- `ratings` (jsonb)
- `created_at` (timestamp)
- `has_spoilers` (boolean)
2. Security
- Enable RLS on all tables
- Add policies for authenticated users
- Add special policies for admin/moderator roles
*/
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
username text UNIQUE NOT NULL,
role text NOT NULL DEFAULT 'user',
created_at timestamptz DEFAULT now(),
profile_picture text,
bio text,
is_critic boolean DEFAULT false
);
-- Create media table
CREATE TABLE IF NOT EXISTS media (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
type text NOT NULL,
poster_url text,
backdrop_url text,
overview text,
release_date date,
created_at timestamptz DEFAULT now(),
created_by uuid REFERENCES users(id),
is_published boolean DEFAULT false
);
-- Create reviews table
CREATE TABLE IF NOT EXISTS reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES users(id),
media_id uuid REFERENCES media(id),
content text NOT NULL,
ratings jsonb NOT NULL,
created_at timestamptz DEFAULT now(),
has_spoilers boolean DEFAULT false
);
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE media ENABLE ROW LEVEL SECURITY;
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
-- Users policies
CREATE POLICY "Users can read all users"
ON users FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Users can update own profile"
ON users FOR UPDATE
TO authenticated
USING (auth.uid() = id);
-- Media policies
CREATE POLICY "Anyone can read published media"
ON media FOR SELECT
TO authenticated
USING (is_published = true);
CREATE POLICY "Admins and moderators can manage all media"
ON media FOR ALL
TO authenticated
USING (
EXISTS (
SELECT 1 FROM users
WHERE id = auth.uid()
AND role IN ('admin', 'moderator')
)
);
-- Reviews policies
CREATE POLICY "Anyone can read reviews"
ON reviews FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Users can create reviews"
ON reviews FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own reviews"
ON reviews FOR UPDATE
TO authenticated
USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own reviews"
ON reviews FOR DELETE
TO authenticated
USING (auth.uid() = user_id);

41
tailwind.config.js Normal file
View File

@ -0,0 +1,41 @@
export default {
content: [
"./index.html",
"./src/**/*.{js,jsx}",
],
theme: {
extend: {
colors: {
campfire: {
amber: '#FF9D00',
charcoal: '#2D3748',
ember: '#FF4500',
ash: '#A0AEC0',
light: '#F7FAFC',
dark: '#1A202C'
},
status: {
success: '#38A169',
warning: '#ECC94B',
error: '#E53E3E',
info: '#3182CE'
}
},
animation: {
'flicker': 'flicker 3s linear infinite',
'fade-in': 'fadeIn 0.5s ease-in'
},
keyframes: {
flicker: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.8' }
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
}
}
}
},
plugins: [],
}

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})