Initial commit
This commit is contained in:
commit
daa4ab85d4
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal 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
8
README.md
Normal 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
41
eslint.config.js
Normal 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
13
index.html
Normal 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
6235
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
public/placeholder-poster.jpg
Normal file
1
public/placeholder-poster.jpg
Normal 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
1
public/vite.svg
Normal 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
42
src/App.css
Normal 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
40
src/App.jsx
Normal 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
1
src/assets/react.svg
Normal 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 |
166
src/components/layout/Footer.jsx
Normal file
166
src/components/layout/Footer.jsx
Normal 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>
|
||||
© {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;
|
191
src/components/layout/Header.jsx
Normal file
191
src/components/layout/Header.jsx
Normal 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;
|
37
src/components/media/MediaCard.jsx
Normal file
37
src/components/media/MediaCard.jsx
Normal 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;
|
76
src/components/media/MediaCarousel.jsx
Normal file
76
src/components/media/MediaCarousel.jsx
Normal 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;
|
132
src/components/reviews/RatingChart.jsx
Normal file
132
src/components/reviews/RatingChart.jsx
Normal 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;
|
122
src/components/reviews/ReviewCard.jsx
Normal file
122
src/components/reviews/ReviewCard.jsx
Normal 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;
|
157
src/components/reviews/ReviewForm.jsx
Normal file
157
src/components/reviews/ReviewForm.jsx
Normal 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;
|
17
src/components/ui/Logo.jsx
Normal file
17
src/components/ui/Logo.jsx
Normal 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;
|
119
src/components/ui/SearchBar.jsx
Normal file
119
src/components/ui/SearchBar.jsx
Normal 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;
|
141
src/contexts/AuthContext.jsx
Normal file
141
src/contexts/AuthContext.jsx
Normal 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>
|
||||
);
|
||||
}
|
117
src/contexts/MediaContext.jsx
Normal file
117
src/contexts/MediaContext.jsx
Normal 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
92
src/index.css
Normal 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
10
src/main.jsx
Normal 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>,
|
||||
)
|
213
src/pages/AdminMediaPage.jsx
Normal file
213
src/pages/AdminMediaPage.jsx
Normal 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
188
src/pages/HomePage.jsx
Normal 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
171
src/pages/LoginPage.jsx
Normal 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
378
src/pages/MediaPage.jsx
Normal 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;
|
24
src/pages/NotFoundPage.jsx
Normal file
24
src/pages/NotFoundPage.jsx
Normal 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
390
src/pages/ProfilePage.jsx
Normal 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
164
src/pages/RegisterPage.jsx
Normal 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
1
src/services/firebase.js
Normal file
@ -0,0 +1 @@
|
||||
// This file can be deleted as it's no longer needed
|
131
src/services/mediaService.js
Normal file
131
src/services/mediaService.js
Normal 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
138
src/services/supabase.js
Normal 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
68
src/services/tmdbApi.js
Normal 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
131
supabase/migrations/old.sql
Normal 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
41
tailwind.config.js
Normal 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
7
vite.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
Loading…
Reference in New Issue
Block a user