Compare commits
11 Commits
daa4ab85d4
...
690c18e601
Author | SHA1 | Date | |
---|---|---|---|
![]() |
690c18e601 | ||
![]() |
89bbc975ce | ||
![]() |
561a3426e0 | ||
![]() |
004980a2cf | ||
![]() |
8bda3252cd | ||
![]() |
068794b4a6 | ||
![]() |
c760b48719 | ||
![]() |
18877a2da2 | ||
![]() |
e06bd3c1e6 | ||
![]() |
ee364b60e6 | ||
![]() |
067caac806 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -46,3 +46,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/supabase/.temp
|
||||
|
528
package-lock.json
generated
528
package-lock.json
generated
@ -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",
|
||||
|
@ -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
15
run-migration.ps1
Normal 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
9
setup-supabase.ps1
Normal 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
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
};
|
||||
};
|
@ -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
72
supabase/config.toml
Normal 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 = ""
|
@ -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;
|
Loading…
Reference in New Issue
Block a user