Compare commits

...

11 Commits

Author SHA1 Message Date
degradin
690c18e601 Админ панель создания медиа
Пользователи с ролью "editor" и "admin" могут создавать и в дальнейшем редактировать медиа
2025-05-07 13:38:46 +03:00
degradin
89bbc975ce Страница профиля
Страница учетной записи
2025-05-07 13:37:53 +03:00
degradin
561a3426e0 Страница медиа
Полная страничка медиа для подробной информации
2025-05-07 13:37:25 +03:00
degradin
004980a2cf Страница авторизации
Создана страница-модуль авторизации
2025-05-07 13:36:31 +03:00
degradin
8bda3252cd Главная страница
Создание главной страницы
2025-05-07 13:35:53 +03:00
degradin
068794b4a6 MediaContextHandle
Ручка для взаимодействия с медиа контентом
2025-05-07 13:34:54 +03:00
degradin
c760b48719 Аутентификация
Логика авторизации и регистрации с помощью supabase
2025-05-07 13:33:37 +03:00
degradin
18877a2da2 Тестовая интеграция TMDB 2025-05-07 13:32:41 +03:00
degradin
e06bd3c1e6 supabase init
Установка и настройка supabase
2025-05-07 13:32:02 +03:00
degradin
ee364b60e6 Update .gitignore
Игнорирование временных файлов при пуше
2025-05-07 13:31:26 +03:00
degradin
067caac806 Зависимости и конфиги
Добавлены необходимые зависимости и конфигурационные файлы проекта
2025-05-07 13:29:57 +03:00
16 changed files with 1442 additions and 1302 deletions

1
.gitignore vendored
View File

@ -46,3 +46,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/supabase/.temp

528
package-lock.json generated
View File

@ -13,10 +13,12 @@
"axios": "^1.6.7",
"chart.js": "^4.4.1",
"react": "^18.3.1",
"react-bootstrap": "^2.10.9",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.2"
"react-router-dom": "^6.22.2",
"supabase": "^2.22.12"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
@ -285,6 +287,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
@ -969,6 +980,18 @@
"node": ">=12"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@ -1086,6 +1109,31 @@
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz",
"integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
@ -1095,6 +1143,60 @@
"node": ">=14.0.0"
}
},
"node_modules/@restart/hooks": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
"integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@restart/ui": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz",
"integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@popperjs/core": "^2.11.8",
"@react-aria/ssr": "^3.5.0",
"@restart/hooks": "^0.5.0",
"@types/warning": "^3.0.3",
"dequal": "^2.0.3",
"dom-helpers": "^5.2.0",
"uncontrollable": "^8.0.4",
"warning": "^4.0.3"
},
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
}
},
"node_modules/@restart/ui/node_modules/@restart/hooks": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz",
"integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@restart/ui/node_modules/uncontrollable": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz",
"integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
@ -1449,6 +1551,15 @@
"@supabase/storage-js": "2.7.1"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1538,14 +1649,12 @@
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.20",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
"integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -1562,6 +1671,21 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/warning": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
"integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -1614,6 +1738,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -1919,6 +2052,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/bin-links": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz",
"integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==",
"license": "ISC",
"dependencies": {
"cmd-shim": "^7.0.0",
"npm-normalize-package-bin": "^4.0.0",
"proc-log": "^5.0.0",
"read-cmd-shim": "^5.0.0",
"write-file-atomic": "^6.0.0"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2146,6 +2295,30 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/cmd-shim": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz",
"integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==",
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2234,9 +2407,17 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -2295,7 +2476,6 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -2361,6 +2541,15 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -2388,6 +2577,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2927,6 +3126,29 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -3059,6 +3281,18 @@
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -3386,6 +3620,19 @@
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3417,7 +3664,6 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
@ -3438,6 +3684,15 @@
"node": ">= 0.4"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -4139,17 +4394,42 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
@ -4190,6 +4470,44 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -4217,11 +4535,19 @@
"node": ">=0.10.0"
}
},
"node_modules/npm-normalize-package-bin": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
"integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==",
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -4798,11 +5124,19 @@
"node": ">= 0.8.0"
}
},
"node_modules/proc-log": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
"integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==",
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@ -4810,6 +5144,19 @@
"react-is": "^16.13.1"
}
},
"node_modules/prop-types-extra": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz",
"integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==",
"license": "MIT",
"dependencies": {
"react-is": "^16.3.2",
"warning": "^4.0.0"
},
"peerDependencies": {
"react": ">=0.14.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -4859,6 +5206,37 @@
"node": ">=0.10.0"
}
},
"node_modules/react-bootstrap": {
"version": "2.10.9",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz",
"integrity": "sha512-TJUCuHcxdgYpOqeWmRApM/Dy0+hVsxNRFvq2aRFQuxhNi/+ivOxC5OdWIeHS3agxvzJ4Ev4nDw2ZdBl9ymd/JQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.7",
"@restart/hooks": "^0.4.9",
"@restart/ui": "^1.9.4",
"@types/prop-types": "^15.7.12",
"@types/react-transition-group": "^4.4.6",
"classnames": "^2.3.2",
"dom-helpers": "^5.2.1",
"invariant": "^2.2.4",
"prop-types": "^15.8.1",
"prop-types-extra": "^1.1.0",
"react-transition-group": "^4.4.5",
"uncontrollable": "^7.2.1",
"warning": "^4.0.3"
},
"peerDependencies": {
"@types/react": ">=16.14.8",
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
@ -4895,7 +5273,12 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-refresh": {
@ -4940,6 +5323,22 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -4950,6 +5349,15 @@
"pify": "^2.3.0"
}
},
"node_modules/read-cmd-shim": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz",
"integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==",
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -5336,7 +5744,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@ -5593,6 +6000,25 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supabase": {
"version": "2.22.12",
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.22.12.tgz",
"integrity": "sha512-PWQT+uzwAXcamM/FK60CaWRjVwsX2SGW5vF7edbiTQC6vsNvTBnSIvd1yiXsIpq32uzQFu+iOrayxaTQytNiTw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bin-links": "^5.0.0",
"https-proxy-agent": "^7.0.2",
"node-fetch": "^3.3.2",
"tar": "7.4.3"
},
"bin": {
"supabase": "bin/supabase"
},
"engines": {
"npm": ">=8"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -5678,6 +6104,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tar": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
"license": "ISC",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.0.1",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -5727,6 +6179,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -5837,6 +6295,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/uncontrollable": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.6.3",
"@types/react": ">=16.9.11",
"invariant": "^2.2.4",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"react": ">=15.0.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -5951,6 +6424,24 @@
}
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -6177,6 +6668,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/write-file-atomic": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz",
"integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==",
"license": "ISC",
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",

View File

@ -10,15 +10,17 @@
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.39.3",
"@neondatabase/serverless": "^0.9.0",
"@supabase/supabase-js": "^2.39.3",
"axios": "^1.6.7",
"chart.js": "^4.4.1",
"react": "^18.3.1",
"react-bootstrap": "^2.10.9",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.2"
"react-router-dom": "^6.22.2",
"supabase": "^2.22.12"
},
"devDependencies": {
"@eslint/js": "^9.9.1",

15
run-migration.ps1 Normal file
View File

@ -0,0 +1,15 @@
# Проверяем, установлен ли Supabase CLI
if (!(Get-Command supabase -ErrorAction SilentlyContinue)) {
Write-Host "Supabase CLI не установлен. Запускаем установку..."
.\setup-supabase.ps1
}
# Инициализируем Supabase проект, если еще не инициализирован
if (!(Test-Path "supabase\.temp")) {
Write-Host "Инициализация Supabase проекта..."
supabase init
}
# Запускаем миграцию
Write-Host "Запуск миграции базы данных..."
supabase db push

9
setup-supabase.ps1 Normal file
View File

@ -0,0 +1,9 @@
# Установка Supabase CLI через scoop
if (!(Get-Command scoop -ErrorAction SilentlyContinue)) {
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
irm get.scoop.sh | iex
}
# Установка Supabase CLI
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabase

View File

@ -1,9 +1,8 @@
import {
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import {
supabase,
@ -16,126 +15,145 @@ import {
const AuthContext = createContext();
export function useAuth() {
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
};
export function AuthProvider({ children }) {
export const 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 () => {
// Проверяем текущую сессию при загрузке
const checkSession = async () => {
try {
setError(null);
const user = await getCurrentUser();
if (user) {
setCurrentUser(user);
const profile = await fetchUserProfile(user.id);
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError) throw sessionError;
if (session?.user) {
setCurrentUser(session.user);
// Загружаем профиль пользователя
const { data: profile, error: profileError } = await supabase
.from('users')
.select('*')
.eq('id', session.user.id)
.single();
if (profileError) throw profileError;
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);
} catch (err) {
console.error('Error checking session:', err);
setError('Ошибка проверки сессии');
} finally {
setLoading(false);
}
};
initializeAuth();
checkSession();
// Подписываемся на изменения состояния авторизации
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' && session?.user) {
setCurrentUser(session.user);
try {
const { data: profile, error: profileError } = await supabase
.from('users')
.select('*')
.eq('id', session.user.id)
.single();
if (profileError) throw profileError;
setUserProfile(profile);
} catch (err) {
console.error('Error loading user profile:', err);
setError('Ошибка загрузки профиля');
}
} else if (event === 'SIGNED_OUT') {
setCurrentUser(null);
setUserProfile(null);
}
});
return () => {
if (authSubscription) {
authSubscription.unsubscribe();
}
subscription.unsubscribe();
};
}, [fetchUserProfile]);
}, []);
const signIn = async (email, password) => {
try {
setError(null);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) throw error;
return data;
} catch (err) {
console.error('Error signing in:', err);
setError(err.message || 'Ошибка входа');
throw err;
}
};
const signUp = async (email, password, username) => {
try {
setError(null);
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { username }
}
});
if (error) throw error;
return data;
} catch (err) {
console.error('Error signing up:', err);
setError(err.message || 'Ошибка регистрации');
throw err;
}
};
const signOut = async () => {
try {
setError(null);
const { error } = await supabase.auth.signOut();
if (error) throw error;
setCurrentUser(null);
setUserProfile(null);
} catch (err) {
console.error('Error signing out:', err);
setError('Ошибка при выходе');
throw err;
}
};
const value = {
currentUser,
userProfile,
signup,
login,
logout,
loading,
error,
updateProfile: fetchUserProfile,
signIn,
signUp,
signOut
};
if (loading) {
return <div className="flex justify-center items-center h-screen">Загрузка...</div>;
}
return (
<AuthContext.Provider value={value}>
{!loading && children}
{children}
</AuthContext.Provider>
);
}
};

View File

@ -1,117 +1,90 @@
import { createContext, useContext, useState, useCallback } from "react";
import {
getTrendingMedia,
getMediaById,
searchMedia,
} from "../services/mediaService";
import React, { createContext, useContext, useState } from 'react';
import { searchMedia, getMediaDetails, validateMediaData, formatMediaData } from '../services/mediaService';
import { createMedia } from '../services/supabase';
const MediaContext = createContext();
export function useMedia() {
export const useMedia = () => {
const context = useContext(MediaContext);
if (!context) {
throw new Error("useMedia must be used within a MediaProvider");
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);
export const MediaProvider = ({ children }) => {
const [searchResults, setSearchResults] = useState([]);
const [selectedMedia, setSelectedMedia] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [searchResults, setSearchResults] = useState([]);
const fetchTrendingMedia = useCallback(async () => {
setLoading(true);
setError(null);
const handleSearch = async (query, type = 'movie') => {
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([]);
setLoading(true);
setError(null);
const results = await searchMedia(query, type);
setSearchResults(results);
} catch (error) {
console.error('Search error:', error);
setError('Ошибка при поиске медиа');
} finally {
setLoading(false);
}
}, []);
const fetchMediaDetails = useCallback(async (id) => {
if (!id) {
setError("Invalid media ID");
return;
}
setLoading(true);
setError(null);
setCurrentMedia(null);
};
const handleSelectMedia = async (tmdbId, type) => {
try {
const data = await getMediaById(id);
if (!data) {
throw new Error("Media not found");
setLoading(true);
setError(null);
const details = await getMediaDetails(tmdbId, type);
setSelectedMedia(details);
} catch (error) {
console.error('Error fetching media details:', error);
setError('Ошибка при получении деталей медиа');
} finally {
setLoading(false);
}
};
const handleCreateMedia = async (mediaData) => {
try {
setLoading(true);
setError(null);
// Валидация данных
const errors = validateMediaData(mediaData);
if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
setCurrentMedia(data);
} catch (err) {
setError(err.message || "Failed to fetch media details");
console.error("Fetch details error:", err);
// Форматирование данных
const formattedData = formatMediaData(mediaData);
// Создание медиа
const newMedia = await createMedia(formattedData);
return newMedia;
} catch (error) {
console.error('Error creating media:', error);
setError(error.message || 'Ошибка при создании медиа');
throw error;
} 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,
searchResults,
selectedMedia,
loading,
error,
searchResults,
fetchTrendingMedia,
fetchMediaDetails,
handleSearch,
clearError: () => setError(null),
clearCurrentMedia: () => setCurrentMedia(null),
clearSearchResults: () => setSearchResults([]),
handleSelectMedia,
handleCreateMedia
};
return (
<MediaContext.Provider value={value}>{children}</MediaContext.Provider>
<MediaContext.Provider value={value}>
{children}
</MediaContext.Provider>
);
}
};

View File

@ -1,13 +1,19 @@
import { useState } from "react";
import React, { useEffect, useState } from 'react';
import { useMedia } from '../contexts/MediaContext';
import { listMedia } from '../services/supabase';
import { mediaTypes } from '../services/mediaService';
import { useAuth } from "../contexts/AuthContext";
import { useNavigate } from "react-router-dom";
import { createMedia } from "../services/supabase";
import { useAuth } from "../contexts/AuthContext";
function AdminMediaPage() {
const AdminMediaPage = () => {
const [media, setMedia] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useNavigate();
const { currentUser, userProfile } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [mediaData, setMediaData] = useState({
title: "",
@ -19,21 +25,36 @@ function AdminMediaPage() {
is_published: false,
});
// Check if user has admin/moderator privileges
if (
!userProfile?.role ||
!["admin", "moderator"].includes(userProfile.role)
) {
// Проверка прав доступа
if (!userProfile?.role || !["admin", "editor"].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>
<h2 className="text-xl font-bold mb-2">Доступ запрещен</h2>
<p>У вас нет прав для доступа к этой странице.</p>
</div>
</div>
);
}
useEffect(() => {
const fetchMedia = async () => {
try {
setLoading(true);
setError(null);
const data = await listMedia(null, 1, 100); // Получаем все медиа
setMedia(data || []);
} catch (err) {
console.error('Error fetching media:', err);
setError('Не удалось загрузить медиа');
} finally {
setLoading(false);
}
};
fetchMedia();
}, []); // Запускаем только при монтировании компонента
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setMediaData((prev) => ({
@ -53,161 +74,148 @@ function AdminMediaPage() {
created_by: currentUser.id,
});
navigate(`/media/${newMedia.id}`);
setMedia(prev => [newMedia, ...prev]);
setMediaData({
title: "",
type: "movie",
poster_url: "",
backdrop_url: "",
overview: "",
release_date: "",
is_published: false,
});
} catch (err) {
setError("Failed to create media. Please try again.");
setError("Ошибка при создании медиа. Пожалуйста, попробуйте снова.");
console.error("Error creating media:", err);
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="text-center">Загрузка...</div>;
}
if (error) {
return <div className="text-red-500">{error}</div>;
}
return (
<div className="pt-20 container-custom py-12">
<h1 className="text-3xl font-bold mb-6">Create New Media</h1>
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Управление медиа</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{media.map((item) => (
<div key={`${item.id}-${item.type}`} className="bg-white rounded-lg shadow-md p-4">
<h3 className="text-lg font-semibold mb-2">{item.title}</h3>
<p className="text-gray-600 mb-2">Тип: {item.type}</p>
{item.rating && (
<p className="text-gray-600">Рейтинг: {item.rating}</p>
)}
</div>
))}
</div>
{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 className="bg-campfire-charcoal p-6 rounded-lg mb-8">
<h2 className="text-2xl font-bold mb-4">Создать новое медиа</h2>
{error && (
<div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-campfire-light mb-2" htmlFor="title">
Title *
</label>
<label className="block text-sm font-medium mb-1">Название</label>
<input
type="text"
id="title"
name="title"
value={mediaData.title}
onChange={handleInputChange}
className="input w-full"
required
className="input w-full"
/>
</div>
<div>
<label className="block text-campfire-light mb-2" htmlFor="type">
Type *
</label>
<label className="block text-sm font-medium mb-1">Тип</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>
<option value="movie">Фильм</option>
<option value="series">Сериал</option>
<option value="game">Игра</option>
</select>
</div>
<div>
<label
className="block text-campfire-light mb-2"
htmlFor="poster_url"
>
Poster URL
</label>
<label className="block text-sm font-medium mb-1">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>
<label className="block text-sm font-medium mb-1">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>
<label className="block text-sm font-medium mb-1">Описание</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>
<label className="block text-sm font-medium mb-1">Дата выхода</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"
className="mr-2"
/>
<label className="ml-2 text-campfire-light" htmlFor="is_published">
Publish immediately
</label>
<label className="text-sm font-medium">Опубликовать сразу</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>
<button
type="submit"
disabled={loading}
className="btn-primary w-full"
>
{loading ? "Создание..." : "Создать медиа"}
</button>
</form>
</div>
</div>
);
}
};
export default AdminMediaPage;

View File

@ -1,188 +1,138 @@
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 React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useMedia } from '../contexts/MediaContext';
import { useAuth } from '../contexts/AuthContext';
import { listMedia } from '../services/supabase';
import { mediaTypes } from '../services/mediaService';
import { FiTrendingUp, FiCalendar, FiAward } from "react-icons/fi";
import MediaCarousel from "../components/media/MediaCarousel";
import { getImageUrl } from "../services/tmdbApi";
function HomePage() {
const { fetchTrendingMedia, trendingMovies, trendingTvShows, loading } =
useMedia();
const { currentUser } = useAuth();
const HomePage = () => {
const [media, setMedia] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { user } = useAuth();
useEffect(() => {
fetchTrendingMedia();
}, [fetchTrendingMedia]);
const loadMedia = async () => {
try {
setLoading(true);
setError(null);
const { data, error } = await listMedia();
if (error) {
throw new Error(error);
}
setMedia(data || []);
} catch (err) {
console.error('Error loading media:', err);
setError('Не удалось загрузить контент');
} finally {
setLoading(false);
}
};
// Get featured media (first trending movie with backdrop)
const featuredMedia =
trendingMovies.find((movie) => movie.backdrop_path) || trendingMovies[0];
loadMedia();
}, []);
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 ? (
if (loading) {
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<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>
</div>
</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>
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
</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>
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-campfire-light">
Добро пожаловать в CampFire
</h1>
{user?.role === 'admin' && (
<Link
to="/admin/media"
className="btn-secondary"
>
Управление контентом
</Link>
)}
</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>
{media.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{media.map((item) => (
<Link
key={`${item.id}-${item.type}`}
to={`/media/${item.id}`}
className="group"
>
<div className="bg-campfire-charcoal rounded-lg overflow-hidden shadow-lg border border-campfire-ash/20 transition-all duration-300 hover:shadow-xl hover:border-campfire-amber/30">
<div className="aspect-[2/3] relative">
<img
src={item.poster_url}
alt={item.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-campfire-charcoal/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-campfire-amber/20 text-campfire-amber">
{item.type === 'movie' ? 'Фильм' : 'Сериал'}
</span>
<span className="text-sm text-campfire-ash">
{new Date(item.release_date).getFullYear()}
</span>
</div>
</Link>
))}
</div>
</div>
</>
<h3 className="text-lg font-semibold text-campfire-light mb-1 group-hover:text-campfire-amber transition-colors">
{item.title}
</h3>
<p className="text-sm text-campfire-ash line-clamp-2">
{item.description}
</p>
</div>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-campfire-ash text-lg">
Пока нет доступного контента
</p>
{user?.role === 'admin' && (
<Link
to="/admin/media"
className="inline-block mt-4 btn-primary"
>
Добавить контент
</Link>
)}
</div>
)}
</div>
</div>
);
}
};
export default HomePage;

View File

@ -1,50 +1,25 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
function LoginPage() {
const [formData, setFormData] = useState({
email: "",
password: "",
});
const [error, setError] = useState("");
const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const [error, setError] = useState(null);
const navigate = useNavigate();
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const { signIn } = useAuth();
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 });
setError(null);
await signIn(email, password);
navigate('/');
} catch (err) {
console.error("Login error:", err);
setError(
err.message === "Invalid login credentials"
? "Неверный email или пароль"
: "Ошибка при входе. Попробуйте позже"
);
console.error('Login error:', err);
setError(err.message || 'Ошибка входа');
} finally {
setLoading(false);
}
@ -81,8 +56,8 @@ function LoginPage() {
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full"
placeholder="your@email.com"
required
@ -109,8 +84,8 @@ function LoginPage() {
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full"
placeholder="••••••••"
required
@ -166,6 +141,6 @@ function LoginPage() {
</div>
</div>
);
}
};
export default LoginPage;

View File

@ -1,378 +1,107 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useMedia } from '../contexts/MediaContext';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getMediaById } from '../services/supabase';
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 MediaPage = () => {
const { id } = useParams();
const [searchParams] = useSearchParams();
const mediaType = searchParams.get('type') || 'movie';
const { fetchMediaDetails, currentMedia, loading } = useMedia();
const [media, setMedia] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
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: []
useEffect(() => {
const loadMedia = async () => {
try {
setLoading(true);
setError(null);
const data = await getMediaById(id);
setMedia(data);
} catch (err) {
console.error('Error loading media:', err);
setError('Не удалось загрузить информацию о медиа');
} finally {
setLoading(false);
}
};
setReviews(prevReviews => [newReview, ...prevReviews]);
return Promise.resolve();
};
if (id) {
loadMedia();
}
}, [id]);
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>
);
return <div className="text-center">Загрузка...</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>
);
if (error) {
return <div className="text-red-500">{error}</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];
if (!media) {
return <div className="text-center">Медиа не найдено</div>;
}
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="container mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Заголовок и основная информация */}
<div className="p-6">
<h1 className="text-3xl font-bold mb-4">{media.title}</h1>
<div className="flex flex-wrap gap-4 text-gray-600 mb-4">
<span>Тип: {media.type}</span>
{media.release_date && (
<span>Дата выхода: {new Date(media.release_date).toLocaleDateString()}</span>
)}
{media.rating && (
<span>Рейтинг: {media.rating.toFixed(1)}</span>
)}
<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>
{media.description && (
<p className="text-gray-700 mb-6">{media.description}</p>
)}
</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>
{/* Постер и дополнительная информация */}
{media.poster_url && (
<div className="p-6 border-t">
<img
src={media.poster_url}
alt={media.title}
className="max-w-sm mx-auto rounded-lg shadow-md"
/>
</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 className="p-6 border-t">
<h2 className="text-2xl font-bold mb-4">Рецензии</h2>
{media.reviews && media.reviews.length > 0 ? (
<div className="space-y-4">
{media.reviews.map((review) => (
<div key={review.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="font-semibold">{review.user.username}</span>
{review.user.is_critic && (
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
Критик
</span>
)}
</div>
<p className="text-gray-700">{review.content}</p>
<div className="mt-2 text-sm text-gray-500">
{new Date(review.created_at).toLocaleDateString()}
</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>
) : (
<p className="text-gray-500">Пока нет рецензий</p>
)}
</div>
</div>
</div>
);
}
};
export default MediaPage;

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { getUserProfile, getUserReviews } from "../services/supabase";
import {
FiEdit,
FiSettings,
@ -14,374 +15,156 @@ import ReviewCard from "../components/reviews/ReviewCard";
import RatingChart from "../components/reviews/RatingChart";
function ProfilePage() {
const { id } = useParams();
const { currentUser, userProfile, logout } = useAuth();
const { userId } = useParams();
const navigate = useNavigate();
const { currentUser, userProfile } = useAuth();
const [profile, setProfile] = useState(null);
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
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]);
const loadProfile = async () => {
try {
setLoading(true);
setError(null);
// Если userId не указан, показываем профиль текущего пользователя
const targetUserId = userId || currentUser?.id;
if (!targetUserId) {
navigate('/login');
return;
}
// 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,
},
];
const profileData = await getUserProfile(targetUserId);
setProfile(profileData);
setUserReviews(mockReviews);
}, [id, userData]);
const reviewsData = await getUserReviews(targetUserId);
setReviews(reviewsData);
} catch (err) {
setError("Ошибка при загрузке профиля");
console.error("Error loading profile:", err);
} finally {
setLoading(false);
}
};
if (!userData) {
loadProfile();
}, [userId, currentUser, navigate]);
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 className="pt-20 container-custom py-12">
<div className="animate-pulse">
<div className="h-32 bg-campfire-charcoal rounded-lg mb-6"></div>
<div className="space-y-4">
<div className="h-4 bg-campfire-charcoal rounded w-1/4"></div>
<div className="h-4 bg-campfire-charcoal rounded w-1/2"></div>
</div>
</div>
</div>
);
}
// Calculate average ratings from reviews
const calculateAverageRatings = () => {
if (!userReviews.length) return null;
if (error) {
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">Ошибка</h2>
<p>{error}</p>
</div>
</div>
);
}
const totals = {
story: 0,
visuals: 0,
performance: 0,
soundtrack: 0,
enjoyment: 0,
};
if (!profile) {
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">Профиль не найден</h2>
<p>Пользователь с таким ID не существует.</p>
</div>
</div>
);
}
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",
});
const isOwnProfile = currentUser?.id === profile.id;
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>
<div className="pt-20 container-custom py-12">
<div className="bg-campfire-charcoal rounded-lg overflow-hidden">
{/* Заголовок профиля */}
<div className="relative h-48 bg-gradient-to-r from-campfire-amber to-campfire-ember">
{profile.profile_picture && (
<img
src={profile.profile_picture}
alt={profile.username}
className="absolute -bottom-16 left-8 w-32 h-32 rounded-full border-4 border-campfire-charcoal object-cover"
/>
)}
</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} еще не написал ничего о себе.}`}
<div className="pt-20 px-8 pb-8">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold mb-2">{profile.username}</h1>
<p className="text-campfire-ash">
{profile.is_critic ? "Критик" : "Пользователь"}
</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>
{isOwnProfile && (
<button
onClick={() => navigate('/settings')}
className="btn-secondary"
>
Редактировать профиль
</button>
)}
</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>
{profile.bio && (
<div className="mb-8">
<h2 className="text-xl font-bold mb-2">О себе</h2>
<p className="text-campfire-light">{profile.bio}</p>
</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>
)}
<h2 className="text-2xl font-bold mb-4">Отзывы</h2>
{reviews.length === 0 ? (
<p className="text-campfire-ash">Пока нет отзывов</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{reviews.map((review) => (
<div key={review.id} className="card p-4">
<div className="flex items-center mb-4">
<img
src={review.media.poster_url}
alt={review.media.title}
className="w-16 h-24 object-cover rounded-lg mr-4"
/>
<div>
<h3 className="font-bold">{review.media.title}</h3>
<p className="text-sm text-campfire-ash">
{review.media.type === 'movie' ? 'Фильм' :
review.media.type === 'series' ? 'Сериал' : 'Игра'}
</p>
</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} />
<p className="text-sm mb-4 line-clamp-3">{review.content}</p>
<button
onClick={() => navigate(`/media/${review.media_id}`)}
className="btn-secondary w-full"
>
Читать полностью
</button>
</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>
</div>
);

View File

@ -1,11 +1,13 @@
import axios from 'axios';
const IMDB_API_URL = 'https://imdb.iamidiotareyoutoo.com';
const TMDB_API_KEY = import.meta.env.VITE_TMDB_API_KEY;
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
export const mediaTypes = {
MOVIE: 'movie',
TV: 'tv',
ALL: 'all' // Добавлено
TV: 'series',
GAME: 'game'
};
function isLocalStorageAvailable() {
@ -86,15 +88,59 @@ export async function getMediaById(id) {
}
}
export async function searchMedia(query) {
export const searchMedia = async (query, type = 'movie') => {
try {
if (!query || !query.trim()) return [];
return await searchIMDb(query, mediaTypes.ALL);
const response = await fetch(
`${TMDB_BASE_URL}/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}&language=ru-RU`
);
if (!response.ok) {
throw new Error('Failed to fetch from TMDB');
}
const data = await response.json();
return data.results.map(item => ({
title: item.title || item.name,
description: item.overview,
poster_url: item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : null,
release_date: item.release_date || item.first_air_date,
type: type,
tmdb_id: item.id,
rating: item.vote_average
}));
} catch (error) {
console.error('Error searching media:', error);
return [];
throw error;
}
}
};
export const getMediaDetails = async (tmdbId, type = 'movie') => {
try {
const response = await fetch(
`${TMDB_BASE_URL}/${type}/${tmdbId}?api_key=${TMDB_API_KEY}&language=ru-RU`
);
if (!response.ok) {
throw new Error('Failed to fetch media details');
}
const data = await response.json();
return {
title: data.title || data.name,
description: data.overview,
poster_url: data.poster_path ? `https://image.tmdb.org/t/p/w500${data.poster_path}` : null,
release_date: data.release_date || data.first_air_date,
type: type,
tmdb_id: data.id,
rating: data.vote_average,
genres: data.genres.map(g => g.name).join(', '),
runtime: data.runtime || data.episode_run_time?.[0] || null
};
} catch (error) {
console.error('Error fetching media details:', error);
throw error;
}
};
export async function addMedia(mediaData) {
try {
@ -128,4 +174,35 @@ export async function addMedia(mediaData) {
console.error('Error adding media:', error);
throw error;
}
}
}
export const validateMediaData = (data) => {
const errors = [];
if (!data.title?.trim()) {
errors.push('Название обязательно');
}
if (!data.type || !Object.values(mediaTypes).includes(data.type)) {
errors.push('Неверный тип медиа');
}
if (data.rating && (isNaN(data.rating) || data.rating < 0 || data.rating > 10)) {
errors.push('Рейтинг должен быть от 0 до 10');
}
return errors;
};
export const formatMediaData = (data) => {
return {
title: data.title?.trim(),
description: data.description?.trim() || '',
type: data.type,
poster_url: data.poster_url?.trim() || null,
release_date: data.release_date || null,
rating: data.rating ? parseFloat(data.rating) : null,
genres: data.genres?.trim() || '',
runtime: data.runtime ? parseInt(data.runtime) : null
};
};

View File

@ -11,27 +11,39 @@ 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,
try {
// Регистрируем пользователя
const { data, error } = await supabase.auth.signUp({
email,
username,
role: 'user'
password,
options: {
data: { username }
}
});
if (profileError) throw profileError;
return data;
if (error) throw error;
// Создаем профиль в таблице users
const { error: profileError } = await supabase
.from('users')
.insert({
id: data.user.id,
email,
username,
role: 'user',
created_at: new Date().toISOString()
});
if (profileError) {
// Если не удалось создать профиль, удаляем пользователя
await supabase.auth.admin.deleteUser(data.user.id);
throw profileError;
}
return data;
} catch (error) {
throw error;
}
};
export const signIn = async (email, password) => {
@ -57,7 +69,7 @@ export const getUserProfile = async (userId) => {
.from('users')
.select('*')
.eq('id', userId)
.single();
.maybeSingle();
if (error) throw error;
return data;
@ -65,14 +77,33 @@ export const getUserProfile = async (userId) => {
// Media functions
export const createMedia = async (mediaData) => {
const { data, error } = await supabase
.from('media')
.insert(mediaData)
.select()
.single();
if (error) throw error;
return data;
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Пользователь не авторизован');
}
const { data, error } = await supabase
.from('media')
.insert({
...mediaData,
created_by: user.id,
created_at: new Date().toISOString(),
is_published: false
})
.select()
.single();
if (error) {
console.error('Error creating media:', error);
throw error;
}
return data;
} catch (error) {
console.error('Failed to create media:', error);
throw error;
}
};
export const getMediaById = async (id) => {
@ -93,22 +124,35 @@ export const getMediaById = async (id) => {
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;
export const listMedia = async (type = null, page = 1, limit = 20) => {
try {
let query = supabase
.from('media')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false });
if (type) {
query = query.eq('type', type);
}
const { data, error, count } = await query
.range((page - 1) * limit, page * limit - 1);
if (error) throw error;
return {
data,
count,
error: null
};
} catch (error) {
console.error('Error in listMedia:', error);
return {
data: [],
count: 0,
error: error.message
};
}
};
// Review functions

72
supabase/config.toml Normal file
View File

@ -0,0 +1,72 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the working
# directory name when running `supabase init`.
project_id = "campfire-critics"
[api]
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. public and storage are always included.
schemas = ["public", "storage"]
# Extra schemas to add to the search_path of every request. public is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54322
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
[studio]
# Port to use for Supabase Studio.
port = 54323
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
# Port to use for the email testing server web interface.
port = 54324
smtp_port = 54325
pop3_port = 54326
[storage]
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
[auth]
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://localhost:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one
# week).
jwt_expiry = 3600
# Allow/disallow new user signups to your project.
enable_signup = true
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# Use an external OAuth provider. The full list of providers are: "apple", "azure", "bitbucket",
# "discord", "facebook", "github", "gitlab", "google", "keycloak", "linkedin", "notion", "twitch",
# "twitter", "slack", "spotify", "workos", "zoom".
[auth.external.apple]
enabled = false
client_id = ""
secret = ""
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""

View File

@ -1,61 +1,20 @@
/*
# 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
-- Создаем таблицу users, если она не существует
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(),
is_critic boolean DEFAULT false,
profile_picture text,
bio text,
is_critic boolean DEFAULT false
created_at timestamptz DEFAULT now()
);
-- Create media table
-- Создаем таблицу media, если она не существует
CREATE TABLE IF NOT EXISTS media (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
type text NOT NULL,
type text NOT NULL DEFAULT 'movie',
poster_url text,
backdrop_url text,
overview text,
@ -65,7 +24,7 @@ CREATE TABLE IF NOT EXISTS media (
is_published boolean DEFAULT false
);
-- Create reviews table
-- Создаем таблицу reviews, если она не существует
CREATE TABLE IF NOT EXISTS reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES users(id),
@ -76,12 +35,23 @@ CREATE TABLE IF NOT EXISTS reviews (
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;
-- Создаем индексы для оптимизации запросов
CREATE INDEX IF NOT EXISTS idx_media_type ON media(type);
CREATE INDEX IF NOT EXISTS idx_media_created_by ON media(created_by);
CREATE INDEX IF NOT EXISTS idx_reviews_user_id ON reviews(user_id);
CREATE INDEX IF NOT EXISTS idx_reviews_media_id ON reviews(media_id);
-- Users policies
-- Обновляем политики безопасности
DROP POLICY IF EXISTS "Users can read all users" ON users;
DROP POLICY IF EXISTS "Users can update own profile" ON users;
DROP POLICY IF EXISTS "Anyone can read published media" ON media;
DROP POLICY IF EXISTS "Admins and editors can manage all media" ON media;
DROP POLICY IF EXISTS "Anyone can read reviews" ON reviews;
DROP POLICY IF EXISTS "Users can create reviews" ON reviews;
DROP POLICY IF EXISTS "Users can update own reviews" ON reviews;
DROP POLICY IF EXISTS "Users can delete own reviews" ON reviews;
-- Политики для users
CREATE POLICY "Users can read all users"
ON users FOR SELECT
TO authenticated
@ -92,24 +62,29 @@ CREATE POLICY "Users can update own profile"
TO authenticated
USING (auth.uid() = id);
-- Media policies
CREATE POLICY "Users can insert own profile"
ON users FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = id);
-- Политики для media
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"
CREATE POLICY "Admins and editors 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')
AND role IN ('admin', 'editor')
)
);
-- Reviews policies
-- Политики для reviews
CREATE POLICY "Anyone can read reviews"
ON reviews FOR SELECT
TO authenticated
@ -128,4 +103,9 @@ CREATE POLICY "Users can update own reviews"
CREATE POLICY "Users can delete own reviews"
ON reviews FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
USING (auth.uid() = user_id);
-- Включаем Row Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE media ENABLE ROW LEVEL SECURITY;
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;