Compare commits

..

No commits in common. "690c18e6011f58ca853a03e230f287904c6e1de8" and "daa4ab85d48387e8a5c62b45e2225ce5f3489629" have entirely different histories.

16 changed files with 1312 additions and 1452 deletions

1
.gitignore vendored
View File

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

528
package-lock.json generated
View File

@ -13,12 +13,10 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-bootstrap": "^2.10.9",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.2", "react-router-dom": "^6.22.2"
"supabase": "^2.22.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^9.9.1",
@ -287,15 +285,6 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.27.0", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
@ -980,18 +969,6 @@
"node": ">=12" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8", "version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@ -1109,31 +1086,6 @@
"node": ">=14" "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": { "node_modules/@remix-run/router": {
"version": "1.23.0", "version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
@ -1143,60 +1095,6 @@
"node": ">=14.0.0" "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.0", "version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
@ -1551,15 +1449,6 @@
"@supabase/storage-js": "2.7.1" "@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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1649,12 +1538,14 @@
"version": "15.7.14", "version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.20", "version": "18.3.20",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
"integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@ -1671,21 +1562,6 @@
"@types/react": "^18.0.0" "@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": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -1738,15 +1614,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "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": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -2052,22 +1919,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2295,30 +2146,6 @@
"node": ">= 6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2407,17 +2234,9 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT" "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": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -2476,6 +2295,7 @@
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -2541,15 +2361,6 @@
"node": ">=0.4.0" "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": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -2577,16 +2388,6 @@
"node": ">=0.10.0" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3126,29 +2927,6 @@
"reusify": "^1.0.4" "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -3281,18 +3059,6 @@
"node": ">= 6" "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": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -3620,19 +3386,6 @@
"node": ">= 0.4" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3664,6 +3417,7 @@
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.8.19" "node": ">=0.8.19"
@ -3684,15 +3438,6 @@
"node": ">= 0.4" "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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -4394,42 +4139,17 @@
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mz": { "node_modules/mz": {
@ -4470,44 +4190,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -4535,19 +4217,11 @@
"node": ">=0.10.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -5124,19 +4798,11 @@
"node": ">= 0.8.0" "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": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@ -5144,19 +4810,6 @@
"react-is": "^16.13.1" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -5206,37 +4859,6 @@
"node": ">=0.10.0" "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": { "node_modules/react-chartjs-2": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
@ -5273,12 +4895,7 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "dev": true,
},
"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" "license": "MIT"
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
@ -5323,22 +4940,6 @@
"react-dom": ">=16.8" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -5349,15 +4950,6 @@
"pify": "^2.3.0" "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": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -5744,6 +5336,7 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@ -6000,25 +5593,6 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -6104,32 +5678,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -6179,12 +5727,6 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -6295,21 +5837,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -6424,24 +5951,6 @@
} }
} }
}, },
"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": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -6668,19 +6177,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/ws": {
"version": "8.18.2", "version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",

View File

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

View File

@ -1,15 +0,0 @@
# Проверяем, установлен ли 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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,13 @@
import React, { useEffect, useState } from 'react'; import { 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 { useNavigate } from "react-router-dom";
import { createMedia } from "../services/supabase"; import { createMedia } from "../services/supabase";
import { useAuth } from "../contexts/AuthContext";
const AdminMediaPage = () => { function AdminMediaPage() {
const [media, setMedia] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { currentUser, userProfile } = useAuth(); const { currentUser, userProfile } = useAuth();
const [page, setPage] = useState(1); const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); const [error, setError] = useState("");
const [mediaData, setMediaData] = useState({ const [mediaData, setMediaData] = useState({
title: "", title: "",
@ -25,36 +19,21 @@ const AdminMediaPage = () => {
is_published: false, is_published: false,
}); });
// Проверка прав доступа // Check if user has admin/moderator privileges
if (!userProfile?.role || !["admin", "editor"].includes(userProfile.role)) { if (
!userProfile?.role ||
!["admin", "moderator"].includes(userProfile.role)
) {
return ( return (
<div className="pt-20 container-custom py-12"> <div className="pt-20 container-custom py-12">
<div className="bg-status-error bg-opacity-20 text-status-error p-6 rounded-lg"> <div className="bg-status-error bg-opacity-20 text-status-error p-6 rounded-lg">
<h2 className="text-xl font-bold mb-2">Доступ запрещен</h2> <h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p>У вас нет прав для доступа к этой странице.</p> <p>You don't have permission to access this page.</p>
</div> </div>
</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 handleInputChange = (e) => {
const { name, value, type, checked } = e.target; const { name, value, type, checked } = e.target;
setMediaData((prev) => ({ setMediaData((prev) => ({
@ -74,119 +53,123 @@ const AdminMediaPage = () => {
created_by: currentUser.id, created_by: currentUser.id,
}); });
setMedia(prev => [newMedia, ...prev]); navigate(`/media/${newMedia.id}`);
setMediaData({
title: "",
type: "movie",
poster_url: "",
backdrop_url: "",
overview: "",
release_date: "",
is_published: false,
});
} catch (err) { } catch (err) {
setError("Ошибка при создании медиа. Пожалуйста, попробуйте снова."); setError("Failed to create media. Please try again.");
console.error("Error creating media:", err); console.error("Error creating media:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (loading) {
return <div className="text-center">Загрузка...</div>;
}
if (error) {
return <div className="text-red-500">{error}</div>;
}
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="pt-20 container-custom py-12">
<h1 className="text-3xl font-bold mb-8">Управление медиа</h1> <h1 className="text-3xl font-bold mb-6">Create New Media</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>
{/* Форма создания медиа */}
<div className="bg-campfire-charcoal p-6 rounded-lg mb-8">
<h2 className="text-2xl font-bold mb-4">Создать новое медиа</h2>
{error && ( {error && (
<div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-lg mb-4"> <div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-md mb-6">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="max-w-2xl">
<div className="space-y-6">
<div> <div>
<label className="block text-sm font-medium mb-1">Название</label> <label className="block text-campfire-light mb-2" htmlFor="title">
Title *
</label>
<input <input
type="text" type="text"
id="title"
name="title" name="title"
value={mediaData.title} value={mediaData.title}
onChange={handleInputChange} onChange={handleInputChange}
required
className="input w-full" className="input w-full"
required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Тип</label> <label className="block text-campfire-light mb-2" htmlFor="type">
Type *
</label>
<select <select
id="type"
name="type" name="type"
value={mediaData.type} value={mediaData.type}
onChange={handleInputChange} onChange={handleInputChange}
className="input w-full" className="input w-full"
required
> >
<option value="movie">Фильм</option> <option value="movie">Movie</option>
<option value="series">Сериал</option> <option value="tv">TV Show</option>
<option value="game">Игра</option> <option value="game">Game</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">URL постера</label> <label
className="block text-campfire-light mb-2"
htmlFor="poster_url"
>
Poster URL
</label>
<input <input
type="url" type="url"
id="poster_url"
name="poster_url" name="poster_url"
value={mediaData.poster_url} value={mediaData.poster_url}
onChange={handleInputChange} onChange={handleInputChange}
className="input w-full" className="input w-full"
placeholder="https://example.com/poster.jpg"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">URL фона</label> <label
className="block text-campfire-light mb-2"
htmlFor="backdrop_url"
>
Backdrop URL
</label>
<input <input
type="url" type="url"
id="backdrop_url"
name="backdrop_url" name="backdrop_url"
value={mediaData.backdrop_url} value={mediaData.backdrop_url}
onChange={handleInputChange} onChange={handleInputChange}
className="input w-full" className="input w-full"
placeholder="https://example.com/backdrop.jpg"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Описание</label> <label
className="block text-campfire-light mb-2"
htmlFor="overview"
>
Overview *
</label>
<textarea <textarea
id="overview"
name="overview" name="overview"
value={mediaData.overview} value={mediaData.overview}
onChange={handleInputChange} onChange={handleInputChange}
className="input w-full h-32" className="input w-full h-32"
required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Дата выхода</label> <label
className="block text-campfire-light mb-2"
htmlFor="release_date"
>
Release Date
</label>
<input <input
type="date" type="date"
id="release_date"
name="release_date" name="release_date"
value={mediaData.release_date} value={mediaData.release_date}
onChange={handleInputChange} onChange={handleInputChange}
@ -197,25 +180,34 @@ const AdminMediaPage = () => {
<div className="flex items-center"> <div className="flex items-center">
<input <input
type="checkbox" type="checkbox"
id="is_published"
name="is_published" name="is_published"
checked={mediaData.is_published} checked={mediaData.is_published}
onChange={handleInputChange} onChange={handleInputChange}
className="mr-2" className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber"
/> />
<label className="text-sm font-medium">Опубликовать сразу</label> <label className="ml-2 text-campfire-light" htmlFor="is_published">
Publish immediately
</label>
</div> </div>
<div className="flex justify-end gap-4">
<button <button
type="submit" type="button"
onClick={() => navigate(-1)}
className="btn-secondary"
disabled={loading} disabled={loading}
className="btn-primary w-full"
> >
{loading ? "Создание..." : "Создать медиа"} Cancel
</button> </button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Creating..." : "Create Media"}
</button>
</div>
</div>
</form> </form>
</div> </div>
</div>
); );
}; }
export default AdminMediaPage; export default AdminMediaPage;

View File

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

View File

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

View File

@ -1,107 +1,378 @@
import React, { useEffect, useState } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { getMediaById } from '../services/supabase'; import { useMedia } from '../contexts/MediaContext';
import { useAuth } from '../contexts/AuthContext'; 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';
const MediaPage = () => { function MediaPage() {
const { id } = useParams(); const { id } = useParams();
const [media, setMedia] = useState(null); const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(true); const mediaType = searchParams.get('type') || 'movie';
const [error, setError] = useState(null);
const { fetchMediaDetails, currentMedia, loading } = useMedia();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const [activeTab, setActiveTab] = useState('overview');
const [reviews, setReviews] = useState([]);
useEffect(() => { useEffect(() => {
const loadMedia = async () => { if (id && mediaType) {
try { fetchMediaDetails(id, mediaType);
setLoading(true); window.scrollTo(0, 0);
setError(null);
const data = await getMediaById(id);
setMedia(data);
} catch (err) {
console.error('Error loading media:', err);
setError('Не удалось загрузить информацию о медиа');
} finally {
setLoading(false);
} }
}, [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: []
}; };
if (id) { setReviews(prevReviews => [newReview, ...prevReviews]);
loadMedia(); return Promise.resolve();
} };
}, [id]);
if (loading) { if (loading) {
return <div className="text-center">Загрузка...</div>; return (
<div className="pt-20 flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
} }
if (error) { if (!currentMedia) {
return <div className="text-red-500">{error}</div>; 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 (!media) { // Extract media details
return <div className="text-center">Медиа не найдено</div>; const {
} backdrop_path,
poster_path,
title,
name,
overview,
vote_average,
release_date,
first_air_date,
runtime,
episode_run_time,
genres = [],
credits = { cast: [], crew: [] },
videos = { results: [] },
similar = { results: [] },
recommendations = { results: [] }
} = currentMedia;
const mediaTitle = title || name;
const releaseDate = release_date || first_air_date;
const duration = runtime || (episode_run_time && episode_run_time[0]);
const backdropUrl = getImageUrl(backdrop_path, 'original');
const posterUrl = getImageUrl(poster_path, 'w342');
// Format release date
const formattedDate = releaseDate ? new Date(releaseDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : 'Unknown';
// Format duration
const formattedDuration = duration ? `${Math.floor(duration / 60)}h ${duration % 60}m` : 'Unknown';
// Get trailer
const trailer = videos.results.find(video => video.type === 'Trailer') || videos.results[0];
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="pt-16">
<div className="bg-white rounded-lg shadow-lg overflow-hidden"> {/* Hero Section */}
{/* Заголовок и основная информация */} <div className="relative w-full h-[500px] md:h-[600px]">
<div className="p-6"> {backdropUrl ? (
<h1 className="text-3xl font-bold mb-4">{media.title}</h1> <>
<div className="flex flex-wrap gap-4 text-gray-600 mb-4"> <div className="absolute inset-0">
<span>Тип: {media.type}</span>
{media.release_date && (
<span>Дата выхода: {new Date(media.release_date).toLocaleDateString()}</span>
)}
{media.rating && (
<span>Рейтинг: {media.rating.toFixed(1)}</span>
)}
</div>
{media.description && (
<p className="text-gray-700 mb-6">{media.description}</p>
)}
</div>
{/* Постер и дополнительная информация */}
{media.poster_url && (
<div className="p-6 border-t">
<img <img
src={media.poster_url} src={backdropUrl}
alt={media.title} alt={mediaTitle}
className="max-w-sm mx-auto rounded-lg shadow-md" 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>
)} )}
{/* Рецензии */} <div className="text-center md:text-left">
<div className="p-6 border-t"> <h1 className="text-3xl md:text-5xl font-bold mb-2">{mediaTitle}</h1>
<h2 className="text-2xl font-bold mb-4">Рецензии</h2>
{media.reviews && media.reviews.length > 0 ? ( <div className="flex flex-wrap items-center justify-center md:justify-start gap-4 mb-4 text-sm">
<div className="space-y-4"> {releaseDate && (
{media.reviews.map((review) => ( <div className="flex items-center text-campfire-ash">
<div key={review.id} className="bg-gray-50 p-4 rounded-lg"> <FaCalendar className="mr-1" />
<div className="flex items-center mb-2"> <span>{formattedDate}</span>
<span className="font-semibold">{review.user.username}</span> </div>
{review.user.is_critic && ( )}
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
Критик {duration && (
</span> <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>
<p className="text-gray-700">{review.content}</p>
<div className="mt-2 text-sm text-gray-500"> <div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-6">
{new Date(review.created_at).toLocaleDateString()} {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> </div>
{trailer && (
<a
href={`https://www.youtube.com/watch?v=${trailer.key}`}
target="_blank"
rel="noopener noreferrer"
className="btn-primary inline-flex items-center"
>
Watch Trailer
</a>
)}
</div>
</div>
</div>
</div>
{/* Content Section */}
<div className="container-custom py-12 md:py-24">
{/* Tabs Navigation */}
<div className="border-b border-campfire-charcoal mb-8">
<div className="flex overflow-x-auto space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`pb-4 font-medium ${
activeTab === 'overview'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('reviews')}
className={`pb-4 font-medium ${
activeTab === 'reviews'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Reviews
</button>
<button
onClick={() => setActiveTab('similar')}
className={`pb-4 font-medium ${
activeTab === 'similar'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Similar
</button>
</div>
</div>
{/* Tab Content */}
<div className="mb-12">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div>
<h2 className="text-2xl font-bold mb-4">Synopsis</h2>
<p className="text-campfire-light mb-8">{overview}</p>
{/* Cast Section */}
{credits.cast && credits.cast.length > 0 && (
<div className="mb-8">
<h2 className="text-2xl font-bold mb-4">Cast</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{credits.cast.slice(0, 6).map(person => (
<div key={person.id} className="text-center">
<div className="relative w-full aspect-[2/3] mb-2 bg-campfire-charcoal rounded-lg overflow-hidden">
{person.profile_path ? (
<img
src={getImageUrl(person.profile_path, 'w185')}
alt={person.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FaUser className="text-campfire-ash" size={32} />
</div>
)}
</div>
<h3 className="font-medium text-sm">{person.name}</h3>
<p className="text-campfire-ash text-xs">{person.character}</p>
</div> </div>
))} ))}
</div> </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"
/>
) : ( ) : (
<p className="text-gray-500">Пока нет рецензий</p> <div className="w-full h-full flex items-center justify-center">
<FaUser className="text-campfire-ash" size={18} />
</div>
)}
</div>
<div>
<h3 className="font-medium">{person.name}</h3>
<p className="text-campfire-ash text-sm">{person.job}</p>
</div>
</div>
))
}
</div>
</div>
)}
</div>
)}
{/* Reviews Tab */}
{activeTab === 'reviews' && (
<div>
<h2 className="text-2xl font-bold mb-6">Reviews</h2>
{/* Submit Review Form */}
{currentUser ? (
<div className="mb-8">
<ReviewForm
mediaId={id}
mediaType={mediaType}
onSubmit={handleSubmitReview}
/>
</div>
) : (
<div className="bg-campfire-charcoal rounded-lg p-6 mb-8 text-center">
<p className="text-campfire-ash mb-4">Sign in to write a review</p>
<a href="/login" className="btn-primary">
Sign In
</a>
</div>
)}
{/* Reviews List */}
{reviews.length > 0 ? (
<div className="space-y-6">
{reviews.map(review => (
<ReviewCard key={review.id} review={review} isDetailed={true} />
))}
</div>
) : (
<div className="bg-campfire-charcoal rounded-lg p-8 text-center">
<p className="text-campfire-ash">No reviews yet. Be the first to review!</p>
</div>
)}
</div>
)}
{/* Similar Tab */}
{activeTab === 'similar' && (
<div>
{/* Similar Titles */}
{similar.results && similar.results.length > 0 && (
<div className="mb-12">
<h2 className="text-2xl font-bold mb-6">Similar Titles</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{similar.results.slice(0, 10).map(item => (
<MediaCard key={item.id} media={item} type={mediaType} />
))}
</div>
</div>
)}
{/* Recommendations */}
{recommendations.results && recommendations.results.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-6">Recommendations</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{recommendations.results.slice(0, 10).map(item => (
<MediaCard key={item.id} media={item} type={mediaType} />
))}
</div>
</div>
)}
{(!similar.results || similar.results.length === 0) &&
(!recommendations.results || recommendations.results.length === 0) && (
<div className="bg-campfire-charcoal rounded-lg p-8 text-center">
<p className="text-campfire-ash">No similar content available.</p>
</div>
)}
</div>
)} )}
</div> </div>
</div> </div>
</div> </div>
); );
}; }
export default MediaPage; export default MediaPage;

View File

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

View File

@ -1,13 +1,11 @@
import axios from 'axios'; import axios from 'axios';
const IMDB_API_URL = 'https://imdb.iamidiotareyoutoo.com'; 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 = { export const mediaTypes = {
MOVIE: 'movie', MOVIE: 'movie',
TV: 'series', TV: 'tv',
GAME: 'game' ALL: 'all' // Добавлено
}; };
function isLocalStorageAvailable() { function isLocalStorageAvailable() {
@ -88,59 +86,15 @@ export async function getMediaById(id) {
} }
} }
export const searchMedia = async (query, type = 'movie') => { export async function searchMedia(query) {
try { try {
const response = await fetch( if (!query || !query.trim()) return [];
`${TMDB_BASE_URL}/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}&language=ru-RU` return await searchIMDb(query, mediaTypes.ALL);
);
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) { } catch (error) {
console.error('Error searching media:', error); console.error('Error searching media:', error);
throw error; return [];
} }
}; }
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) { export async function addMedia(mediaData) {
try { try {
@ -175,34 +129,3 @@ export async function addMedia(mediaData) {
throw error; throw error;
} }
} }
export const validateMediaData = (data) => {
const errors = [];
if (!data.title?.trim()) {
errors.push('Название обязательно');
}
if (!data.type || !Object.values(mediaTypes).includes(data.type)) {
errors.push('Неверный тип медиа');
}
if (data.rating && (isNaN(data.rating) || data.rating < 0 || data.rating > 10)) {
errors.push('Рейтинг должен быть от 0 до 10');
}
return errors;
};
export const formatMediaData = (data) => {
return {
title: data.title?.trim(),
description: data.description?.trim() || '',
type: data.type,
poster_url: data.poster_url?.trim() || null,
release_date: data.release_date || null,
rating: data.rating ? parseFloat(data.rating) : null,
genres: data.genres?.trim() || '',
runtime: data.runtime ? parseInt(data.runtime) : null
};
};

View File

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

View File

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

View File

@ -1,20 +1,61 @@
-- Создаем таблицу users, если она не существует /*
# Initial schema setup for CampFire Critics
1. New Tables
- `users`
- `id` (uuid, primary key)
- `email` (text, unique)
- `username` (text, unique)
- `role` (text)
- `created_at` (timestamp)
- `profile_picture` (text)
- `bio` (text)
- `is_critic` (boolean)
- `media`
- `id` (uuid, primary key)
- `title` (text)
- `type` (text)
- `poster_url` (text)
- `backdrop_url` (text)
- `overview` (text)
- `release_date` (date)
- `created_at` (timestamp)
- `created_by` (uuid, references users)
- `is_published` (boolean)
- `reviews`
- `id` (uuid, primary key)
- `user_id` (uuid, references users)
- `media_id` (uuid, references media)
- `content` (text)
- `ratings` (jsonb)
- `created_at` (timestamp)
- `has_spoilers` (boolean)
2. Security
- Enable RLS on all tables
- Add policies for authenticated users
- Add special policies for admin/moderator roles
*/
-- Create users table
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL, email text UNIQUE NOT NULL,
username text UNIQUE NOT NULL, username text UNIQUE NOT NULL,
role text NOT NULL DEFAULT 'user', role text NOT NULL DEFAULT 'user',
is_critic boolean DEFAULT false, created_at timestamptz DEFAULT now(),
profile_picture text, profile_picture text,
bio text, bio text,
created_at timestamptz DEFAULT now() is_critic boolean DEFAULT false
); );
-- Создаем таблицу media, если она не существует -- Create media table
CREATE TABLE IF NOT EXISTS media ( CREATE TABLE IF NOT EXISTS media (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL, title text NOT NULL,
type text NOT NULL DEFAULT 'movie', type text NOT NULL,
poster_url text, poster_url text,
backdrop_url text, backdrop_url text,
overview text, overview text,
@ -24,7 +65,7 @@ CREATE TABLE IF NOT EXISTS media (
is_published boolean DEFAULT false is_published boolean DEFAULT false
); );
-- Создаем таблицу reviews, если она не существует -- Create reviews table
CREATE TABLE IF NOT EXISTS reviews ( CREATE TABLE IF NOT EXISTS reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES users(id), user_id uuid REFERENCES users(id),
@ -35,23 +76,12 @@ CREATE TABLE IF NOT EXISTS reviews (
has_spoilers boolean DEFAULT false has_spoilers boolean DEFAULT false
); );
-- Создаем индексы для оптимизации запросов -- Enable RLS
CREATE INDEX IF NOT EXISTS idx_media_type ON media(type); ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE INDEX IF NOT EXISTS idx_media_created_by ON media(created_by); ALTER TABLE media ENABLE ROW LEVEL SECURITY;
CREATE INDEX IF NOT EXISTS idx_reviews_user_id ON reviews(user_id); ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
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" CREATE POLICY "Users can read all users"
ON users FOR SELECT ON users FOR SELECT
TO authenticated TO authenticated
@ -62,29 +92,24 @@ CREATE POLICY "Users can update own profile"
TO authenticated TO authenticated
USING (auth.uid() = id); USING (auth.uid() = id);
CREATE POLICY "Users can insert own profile" -- Media policies
ON users FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = id);
-- Политики для media
CREATE POLICY "Anyone can read published media" CREATE POLICY "Anyone can read published media"
ON media FOR SELECT ON media FOR SELECT
TO authenticated TO authenticated
USING (is_published = true); USING (is_published = true);
CREATE POLICY "Admins and editors can manage all media" CREATE POLICY "Admins and moderators can manage all media"
ON media FOR ALL ON media FOR ALL
TO authenticated TO authenticated
USING ( USING (
EXISTS ( EXISTS (
SELECT 1 FROM users SELECT 1 FROM users
WHERE id = auth.uid() WHERE id = auth.uid()
AND role IN ('admin', 'editor') AND role IN ('admin', 'moderator')
) )
); );
-- Политики для reviews -- Reviews policies
CREATE POLICY "Anyone can read reviews" CREATE POLICY "Anyone can read reviews"
ON reviews FOR SELECT ON reviews FOR SELECT
TO authenticated TO authenticated
@ -104,8 +129,3 @@ CREATE POLICY "Users can delete own reviews"
ON reviews FOR DELETE ON reviews FOR DELETE
TO authenticated 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;