Compare commits

..

11 Commits

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

1
.gitignore vendored
View File

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

528
package-lock.json generated
View File

@ -13,10 +13,12 @@
"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",
@ -285,6 +287,15 @@
"@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",
@ -969,6 +980,18 @@
"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",
@ -1086,6 +1109,31 @@
"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",
@ -1095,6 +1143,60 @@
"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",
@ -1449,6 +1551,15 @@
"@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",
@ -1538,14 +1649,12 @@
"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": "*",
@ -1562,6 +1671,21 @@
"@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",
@ -1614,6 +1738,15 @@
"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",
@ -1919,6 +2052,22 @@
"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",
@ -2146,6 +2295,30 @@
"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",
@ -2234,9 +2407,17 @@
"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",
@ -2295,7 +2476,6 @@
"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"
@ -2361,6 +2541,15 @@
"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",
@ -2388,6 +2577,16 @@
"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",
@ -2927,6 +3126,29 @@
"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",
@ -3059,6 +3281,18 @@
"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",
@ -3386,6 +3620,19 @@
"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",
@ -3417,7 +3664,6 @@
"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"
@ -3438,6 +3684,15 @@
"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",
@ -4139,17 +4394,42 @@
"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": {
@ -4190,6 +4470,44 @@
"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",
@ -4217,11 +4535,19 @@
"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"
@ -4798,11 +5124,19 @@
"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",
@ -4810,6 +5144,19 @@
"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",
@ -4859,6 +5206,37 @@
"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",
@ -4895,7 +5273,12 @@
"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==",
"dev": true, "license": "MIT"
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
@ -4940,6 +5323,22 @@
"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",
@ -4950,6 +5349,15 @@
"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",
@ -5336,7 +5744,6 @@
"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"
@ -5593,6 +6000,25 @@
"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",
@ -5678,6 +6104,32 @@
"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",
@ -5727,6 +6179,12 @@
"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",
@ -5837,6 +6295,21 @@
"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",
@ -5951,6 +6424,24 @@
} }
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": { "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",
@ -6177,6 +6668,19 @@
"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,15 +10,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.39.3",
"@neondatabase/serverless": "^0.9.0", "@neondatabase/serverless": "^0.9.0",
"@supabase/supabase-js": "^2.39.3",
"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",

15
run-migration.ps1 Normal file
View File

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

9
setup-supabase.ps1 Normal file
View File

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

View File

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

View File

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

View File

@ -1,13 +1,19 @@
import { useState } from "react"; import React, { useEffect, useState } from 'react';
import { useMedia } from '../contexts/MediaContext';
import { listMedia } from '../services/supabase';
import { mediaTypes } from '../services/mediaService';
import { useAuth } from "../contexts/AuthContext";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createMedia } from "../services/supabase"; import { createMedia } from "../services/supabase";
import { useAuth } from "../contexts/AuthContext";
function AdminMediaPage() { const AdminMediaPage = () => {
const [media, setMedia] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { currentUser, userProfile } = useAuth(); const { currentUser, userProfile } = useAuth();
const [loading, setLoading] = useState(false); const [page, setPage] = useState(1);
const [error, setError] = useState(""); const [hasMore, setHasMore] = useState(true);
const [mediaData, setMediaData] = useState({ const [mediaData, setMediaData] = useState({
title: "", title: "",
@ -19,21 +25,36 @@ function AdminMediaPage() {
is_published: false, is_published: false,
}); });
// Check if user has admin/moderator privileges // Проверка прав доступа
if ( if (!userProfile?.role || !["admin", "editor"].includes(userProfile.role)) {
!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">Access Denied</h2> <h2 className="text-xl font-bold mb-2">Доступ запрещен</h2>
<p>You don't have permission to access this page.</p> <p>У вас нет прав для доступа к этой странице.</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) => ({
@ -53,123 +74,119 @@ function AdminMediaPage() {
created_by: currentUser.id, created_by: currentUser.id,
}); });
navigate(`/media/${newMedia.id}`); setMedia(prev => [newMedia, ...prev]);
setMediaData({
title: "",
type: "movie",
poster_url: "",
backdrop_url: "",
overview: "",
release_date: "",
is_published: false,
});
} catch (err) { } catch (err) {
setError("Failed to create media. Please try again."); setError("Ошибка при создании медиа. Пожалуйста, попробуйте снова.");
console.error("Error creating media:", err); console.error("Error creating media:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( if (loading) {
<div className="pt-20 container-custom py-12"> return <div className="text-center">Загрузка...</div>;
<h1 className="text-3xl font-bold mb-6">Create New Media</h1> }
if (error) {
return <div className="text-red-500">{error}</div>;
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Управление медиа</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{media.map((item) => (
<div key={`${item.id}-${item.type}`} className="bg-white rounded-lg shadow-md p-4">
<h3 className="text-lg font-semibold mb-2">{item.title}</h3>
<p className="text-gray-600 mb-2">Тип: {item.type}</p>
{item.rating && (
<p className="text-gray-600">Рейтинг: {item.rating}</p>
)}
</div>
))}
</div>
{/* Форма создания медиа */}
<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-md mb-6"> <div className="bg-status-error bg-opacity-20 text-status-error p-4 rounded-lg mb-4">
{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-campfire-light mb-2" htmlFor="title"> <label className="block text-sm font-medium mb-1">Название</label>
Title *
</label>
<input <input
type="text" type="text"
id="title"
name="title" name="title"
value={mediaData.title} value={mediaData.title}
onChange={handleInputChange} onChange={handleInputChange}
className="input w-full"
required required
className="input w-full"
/> />
</div> </div>
<div> <div>
<label className="block text-campfire-light mb-2" htmlFor="type"> <label className="block text-sm font-medium mb-1">Тип</label>
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">Movie</option> <option value="movie">Фильм</option>
<option value="tv">TV Show</option> <option value="series">Сериал</option>
<option value="game">Game</option> <option value="game">Игра</option>
</select> </select>
</div> </div>
<div> <div>
<label <label className="block text-sm font-medium mb-1">URL постера</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 <label className="block text-sm font-medium mb-1">URL фона</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 <label className="block text-sm font-medium mb-1">Описание</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 <label className="block text-sm font-medium mb-1">Дата выхода</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}
@ -180,34 +197,25 @@ function 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="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber" className="mr-2"
/> />
<label className="ml-2 text-campfire-light" htmlFor="is_published"> <label className="text-sm font-medium">Опубликовать сразу</label>
Publish immediately
</label>
</div> </div>
<div className="flex justify-end gap-4">
<button <button
type="button" type="submit"
onClick={() => navigate(-1)}
className="btn-secondary"
disabled={loading} disabled={loading}
className="btn-primary w-full"
> >
Cancel {loading ? "Создание..." : "Создать медиа"}
</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,188 +1,138 @@
import { useEffect } from "react"; import React, { useState, 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 MediaCarousel from "../components/media/MediaCarousel"; import { listMedia } from '../services/supabase';
import { mediaTypes } from '../services/mediaService';
import { FiTrendingUp, FiCalendar, FiAward } from "react-icons/fi"; import { FiTrendingUp, FiCalendar, FiAward } from "react-icons/fi";
import MediaCarousel from "../components/media/MediaCarousel";
import { getImageUrl } from "../services/tmdbApi"; import { getImageUrl } from "../services/tmdbApi";
function HomePage() { const HomePage = () => {
const { fetchTrendingMedia, trendingMovies, trendingTvShows, loading } = const [media, setMedia] = useState([]);
useMedia(); const [loading, setLoading] = useState(true);
const { currentUser } = useAuth(); const [error, setError] = useState(null);
const { user } = useAuth();
useEffect(() => { useEffect(() => {
fetchTrendingMedia(); const loadMedia = async () => {
}, [fetchTrendingMedia]); try {
setLoading(true);
setError(null);
const { data, error } = await listMedia();
// Get featured media (first trending movie with backdrop) if (error) {
const featuredMedia = throw new Error(error);
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="pt-20"> <div className="min-h-screen bg-campfire-dark pt-20">
{/* Hero Section */} <div className="container-custom py-12">
{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>
<MediaCarousel
media={trendingMovies}
mediaType="movie"
seeAllLink="/discover/movies"
/>
</div>
{/* Trending TV Shows */}
<div className="mb-12">
<div className="flex items-center mb-2">
<FiTrendingUp className="text-campfire-amber mr-2" size={20} />
<h2 className="text-2xl font-bold">Сериалы</h2>
</div>
<MediaCarousel
media={trendingTvShows}
mediaType="tv"
seeAllLink="/discover/tv"
/>
</div>
{/* Latest Reviews */}
<div className="mb-12">
<div className="flex items-center mb-6">
<FiCalendar className="text-campfire-amber mr-2" size={20} />
<h2 className="text-2xl font-bold">Последние рецензии</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Placeholder for latest reviews */}
<div className="bg-campfire-charcoal rounded-lg p-6 flex flex-col items-center justify-center text-center h-48">
<p className="text-campfire-ash mb-4">Рецензий пока нет</p>
{!currentUser && (
<Link
to="/login"
className="text-campfire-amber hover:text-campfire-ember"
>
Войдите, чтобы написать рецензию
</Link>
)}
</div>
<div className="bg-campfire-charcoal rounded-lg p-6 flex flex-col items-center justify-center text-center h-48">
<p className="text-campfire-ash mb-4">Рецензий пока нет</p>
{!currentUser && (
<Link
to="/login"
className="text-campfire-amber hover:text-campfire-ember"
>
Войдите, чтобы написать рецензию
</Link>
)}
</div> </div>
</div> </div>
</div> );
{/* Critic's Choice */}
<div className="mb-12">
<div className="flex items-center mb-6">
<FiAward className="text-campfire-amber mr-2" size={20} />
<h2 className="text-2xl font-bold">Выбор Резидентов</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{trendingMovies.slice(0, 3).map((movie) => (
<Link
key={movie.id}
to={`/media/${movie.id}?type=movie`}
className="block"
>
<div className="card h-full">
<div className="relative aspect-video">
<img
src={
getImageUrl(movie.backdrop_path, "w780") ||
getImageUrl(movie.poster_path, "w342")
} }
alt={movie.title}
if (error) {
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
{error}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-campfire-dark pt-20">
<div className="container-custom py-12">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-campfire-light">
Добро пожаловать в CampFire
</h1>
{user?.role === 'admin' && (
<Link
to="/admin/media"
className="btn-secondary"
>
Управление контентом
</Link>
)}
</div>
{media.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{media.map((item) => (
<Link
key={`${item.id}-${item.type}`}
to={`/media/${item.id}`}
className="group"
>
<div className="bg-campfire-charcoal rounded-lg overflow-hidden shadow-lg border border-campfire-ash/20 transition-all duration-300 hover:shadow-xl hover:border-campfire-amber/30">
<div className="aspect-[2/3] relative">
<img
src={item.poster_url}
alt={item.title}
className="w-full h-full object-cover" 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">
<h3 className="font-bold text-lg mb-2"> <div className="flex items-center justify-between mb-2">
{movie.title} <span className="text-xs font-medium px-2 py-1 rounded-full bg-campfire-amber/20 text-campfire-amber">
{item.type === 'movie' ? 'Фильм' : 'Сериал'}
</span>
<span className="text-sm text-campfire-ash">
{new Date(item.release_date).getFullYear()}
</span>
</div>
<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-campfire-ash text-sm line-clamp-2"> <p className="text-sm text-campfire-ash line-clamp-2">
{movie.overview} {item.description}
</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,50 +1,25 @@
import { useState } from "react"; import React, { useState } from 'react';
import { Link, useNavigate } from "react-router-dom"; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from '../contexts/AuthContext';
function LoginPage() { const LoginPage = () => {
const [formData, setFormData] = useState({ const [email, setEmail] = useState('');
email: "", const [password, setPassword] = useState('');
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);
const { error } = await login(formData.email, formData.password); await signIn(email, password);
navigate('/');
if (error) throw error;
navigate("/", { replace: true });
} catch (err) { } catch (err) {
console.error("Login error:", err); console.error('Login error:', err);
setError( setError(err.message || 'Ошибка входа');
err.message === "Invalid login credentials"
? "Неверный email или пароль"
: "Ошибка при входе. Попробуйте позже"
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -81,8 +56,8 @@ function LoginPage() {
type="email" type="email"
id="email" id="email"
name="email" name="email"
value={formData.email} value={email}
onChange={handleChange} onChange={(e) => setEmail(e.target.value)}
className="input w-full" className="input w-full"
placeholder="your@email.com" placeholder="your@email.com"
required required
@ -109,8 +84,8 @@ function LoginPage() {
type="password" type="password"
id="password" id="password"
name="password" name="password"
value={formData.password} value={password}
onChange={handleChange} onChange={(e) => setPassword(e.target.value)}
className="input w-full" className="input w-full"
placeholder="••••••••" placeholder="••••••••"
required required
@ -166,6 +141,6 @@ function LoginPage() {
</div> </div>
</div> </div>
); );
} };
export default LoginPage; export default LoginPage;

View File

@ -1,378 +1,107 @@
import { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useMedia } from '../contexts/MediaContext'; import { getMediaById } from '../services/supabase';
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';
function MediaPage() { const MediaPage = () => {
const { id } = useParams(); const { id } = useParams();
const [searchParams] = useSearchParams(); const [media, setMedia] = useState(null);
const mediaType = searchParams.get('type') || 'movie'; const [loading, setLoading] = useState(true);
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(() => {
if (id && mediaType) { const loadMedia = async () => {
fetchMediaDetails(id, mediaType); try {
window.scrollTo(0, 0); setLoading(true);
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: []
}; };
setReviews(prevReviews => [newReview, ...prevReviews]); if (id) {
return Promise.resolve(); loadMedia();
}; }
}, [id]);
if (loading) { if (loading) {
return ( return <div className="text-center">Загрузка...</div>;
<div className="pt-20 flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
</div>
);
} }
if (!currentMedia) { if (error) {
return ( return <div className="text-red-500">{error}</div>;
<div className="pt-20 container-custom py-16 text-center">
<h1 className="text-3xl font-bold mb-4">Media Not Found</h1>
<p className="text-campfire-ash">The requested media could not be found.</p>
</div>
);
} }
// Extract media details if (!media) {
const { return <div className="text-center">Медиа не найдено</div>;
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="pt-16"> <div className="container mx-auto px-4 py-8">
{/* Hero Section */} <div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative w-full h-[500px] md:h-[600px]"> {/* Заголовок и основная информация */}
{backdropUrl ? ( <div className="p-6">
<> <h1 className="text-3xl font-bold mb-4">{media.title}</h1>
<div className="absolute inset-0"> <div className="flex flex-wrap gap-4 text-gray-600 mb-4">
<img <span>Тип: {media.type}</span>
src={backdropUrl} {media.release_date && (
alt={mediaTitle} <span>Дата выхода: {new Date(media.release_date).toLocaleDateString()}</span>
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>
)} )}
{media.rating && (
<span>Рейтинг: {media.rating.toFixed(1)}</span>
)}
</div>
{media.description && (
<p className="text-gray-700 mb-6">{media.description}</p>
)}
</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"> {media.poster_url && (
{posterUrl && ( <div className="p-6 border-t">
<div className="w-48 md:w-64 flex-shrink-0 rounded-lg overflow-hidden shadow-xl transform md:translate-y-16">
<img <img
src={posterUrl} src={media.poster_url}
alt={mediaTitle} alt={media.title}
className="w-full h-auto" className="max-w-sm mx-auto rounded-lg shadow-md"
/> />
</div> </div>
)} )}
<div className="text-center md:text-left"> {/* Рецензии */}
<h1 className="text-3xl md:text-5xl font-bold mb-2">{mediaTitle}</h1> <div className="p-6 border-t">
<h2 className="text-2xl font-bold mb-4">Рецензии</h2>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 mb-4 text-sm"> {media.reviews && media.reviews.length > 0 ? (
{releaseDate && ( <div className="space-y-4">
<div className="flex items-center text-campfire-ash"> {media.reviews.map((review) => (
<FaCalendar className="mr-1" /> <div key={review.id} className="bg-gray-50 p-4 rounded-lg">
<span>{formattedDate}</span> <div className="flex items-center mb-2">
</div> <span className="font-semibold">{review.user.username}</span>
)} {review.user.is_critic && (
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{duration && ( Критик
<div className="flex items-center text-campfire-ash">
<FaClock className="mr-1" />
<span>{formattedDuration}</span>
</div>
)}
{vote_average > 0 && (
<div className="flex items-center text-campfire-amber">
<FaStar className="mr-1" />
<span>{(vote_average / 2).toFixed(1)}</span>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-6">
{genres.map(genre => (
<span
key={genre.id}
className="inline-block px-3 py-1 rounded-full text-xs font-medium bg-campfire-charcoal"
>
{genre.name}
</span> </span>
))}
</div>
{trailer && (
<a
href={`https://www.youtube.com/watch?v=${trailer.key}`}
target="_blank"
rel="noopener noreferrer"
className="btn-primary inline-flex items-center"
>
Watch Trailer
</a>
)} )}
</div> </div>
<p className="text-gray-700">{review.content}</p>
<div className="mt-2 text-sm text-gray-500">
{new Date(review.created_at).toLocaleDateString()}
</div> </div>
</div> </div>
</div>
{/* Content Section */}
<div className="container-custom py-12 md:py-24">
{/* Tabs Navigation */}
<div className="border-b border-campfire-charcoal mb-8">
<div className="flex overflow-x-auto space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`pb-4 font-medium ${
activeTab === 'overview'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('reviews')}
className={`pb-4 font-medium ${
activeTab === 'reviews'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Reviews
</button>
<button
onClick={() => setActiveTab('similar')}
className={`pb-4 font-medium ${
activeTab === 'similar'
? 'text-campfire-amber border-b-2 border-campfire-amber'
: 'text-campfire-ash hover:text-campfire-light'
}`}
>
Similar
</button>
</div>
</div>
{/* Tab Content */}
<div className="mb-12">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div>
<h2 className="text-2xl font-bold mb-4">Synopsis</h2>
<p className="text-campfire-light mb-8">{overview}</p>
{/* Cast Section */}
{credits.cast && credits.cast.length > 0 && (
<div className="mb-8">
<h2 className="text-2xl font-bold mb-4">Cast</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{credits.cast.slice(0, 6).map(person => (
<div key={person.id} className="text-center">
<div className="relative w-full aspect-[2/3] mb-2 bg-campfire-charcoal rounded-lg overflow-hidden">
{person.profile_path ? (
<img
src={getImageUrl(person.profile_path, 'w185')}
alt={person.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FaUser className="text-campfire-ash" size={32} />
</div>
)}
</div>
<h3 className="font-medium text-sm">{person.name}</h3>
<p className="text-campfire-ash text-xs">{person.character}</p>
</div>
))}
</div>
</div>
)}
{/* Crew Section */}
{credits.crew && credits.crew.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4">Crew</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{credits.crew
.filter(person =>
['Director', 'Producer', 'Writer', 'Screenplay'].includes(person.job)
)
.slice(0, 6)
.map(person => (
<div key={`${person.id}-${person.job}`} className="flex items-center">
<div className="w-12 h-12 rounded-full overflow-hidden bg-campfire-charcoal mr-3 flex-shrink-0">
{person.profile_path ? (
<img
src={getImageUrl(person.profile_path, 'w185')}
alt={person.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FaUser className="text-campfire-ash" size={18} />
</div>
)}
</div>
<div>
<h3 className="font-medium">{person.name}</h3>
<p className="text-campfire-ash text-sm">{person.job}</p>
</div>
</div>
))
}
</div>
</div>
)}
</div>
)}
{/* Reviews Tab */}
{activeTab === 'reviews' && (
<div>
<h2 className="text-2xl font-bold mb-6">Reviews</h2>
{/* Submit Review Form */}
{currentUser ? (
<div className="mb-8">
<ReviewForm
mediaId={id}
mediaType={mediaType}
onSubmit={handleSubmitReview}
/>
</div>
) : (
<div className="bg-campfire-charcoal rounded-lg p-6 mb-8 text-center">
<p className="text-campfire-ash mb-4">Sign in to write a review</p>
<a href="/login" className="btn-primary">
Sign In
</a>
</div>
)}
{/* Reviews List */}
{reviews.length > 0 ? (
<div className="space-y-6">
{reviews.map(review => (
<ReviewCard key={review.id} review={review} isDetailed={true} />
))} ))}
</div> </div>
) : ( ) : (
<div className="bg-campfire-charcoal rounded-lg p-8 text-center"> <p className="text-gray-500">Пока нет рецензий</p>
<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,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { getUserProfile, getUserReviews } from "../services/supabase";
import { import {
FiEdit, FiEdit,
FiSettings, FiSettings,
@ -14,374 +15,156 @@ import ReviewCard from "../components/reviews/ReviewCard";
import RatingChart from "../components/reviews/RatingChart"; import RatingChart from "../components/reviews/RatingChart";
function ProfilePage() { function ProfilePage() {
const { id } = useParams(); const { userId } = useParams();
const { currentUser, userProfile, logout } = useAuth(); const navigate = useNavigate();
const { currentUser, userProfile } = useAuth();
const [profile, setProfile] = useState(null);
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isCurrentUser, setIsCurrentUser] = useState(false);
const [userData, setUserData] = useState(null);
const [activeTab, setActiveTab] = useState("reviews");
const [userReviews, setUserReviews] = useState([]);
// Check if this is the current user's profile
useEffect(() => { useEffect(() => {
if (currentUser && id === currentUser.uid) { const loadProfile = async () => {
setIsCurrentUser(true); try {
setUserData(userProfile); setLoading(true);
} else { setError(null);
setIsCurrentUser(false);
// In a real app, fetch the user data for the profile being viewed // Если userId не указан, показываем профиль текущего пользователя
// For now, we'll use mock data const targetUserId = userId || currentUser?.id;
setUserData({ if (!targetUserId) {
username: "Degradin", navigate('/login');
email: "degradin@campfiregg.ru", return;
bio: "CEO of CampFireGG.",
profilePicture:
"https://staff.campfiregg.ru/assets/avatars/gdVPZMyCy9StMDMy.gif",
createdAt: "2025-01-15T00:00:00.000Z",
reviewCount: 28,
isCritic: true,
});
} }
}, [currentUser, id, userProfile]);
// Mock loading reviews const profileData = await getUserProfile(targetUserId);
useEffect(() => { setProfile(profileData);
// In a real app, fetch user reviews from the database
// For now, we'll use mock data
const mockReviews = [
{
id: "1",
user: {
id: id,
username: userData?.username || "User",
profilePicture: userData?.profilePicture,
isCritic: userData?.isCritic || false,
},
mediaId: "123",
mediaType: "movie",
mediaTitle: "The Last of Us",
mediaPoster:
"https://kinopoisk-ru.clstorage.net/1L67Lp105/810449redVB/ZukoaopfTiUSOnVYJJxkXkBCJLdbT63V4ifXLUdXiOvBUJT00q4PPtjWH9FFjkFNvMIv_JU9XtLv_pkyuD8UF7Osabtd2tgwV_qmziZcZBlH1_OkCM6_RCVxE3vRx4TmTdoPOVoixLZBSc4BVEkVmqkSP-pDL4lBKztFqCiuHg_kIK4mS4GJ5xwqOBon0e1e1gilv4NDYd31K1FnDBKGrwXfBPPQrMTxiUnJxRVQ35uk2bHhSKOA2SkyA6c1qldGKiFr5UNOhmBbq7FZZhsh21mK4f_MBidRt2GTY4lYAeADGg__U2RIfA5HT06X0JMZZ9E4qUsuAxrjvUNl9W8XQWfsoaTOQsfvVig_0mQXppyZxeB5hIsp1fyunOVB2kGkiJtCdVygC3kBhMdEFJ7flCgTN62K5UCR7vbBLzauFAArrmnvA4pGr1NtuN3j0CbanUzttwTHrdwzLVQqzZ8CZ4VWx_MZp8z1QoQATtVfWhQn2P9pQ29JE2Q3Bq92rpjK7-Eh6k1NTmWca3edbdQuWV9DaLfNz6Gbsukd70GewuJGEU333-IDeocHSIARUxFd4dizKopngh5rfMChdGiZwmempO9MActs1yp_UaeV5FIWAS76joOh1HPo3m5G1wjiBlzK8BUshzvPS05HlJpa0eOQeSoK64EbpfgAYvBnV8Ev4Oquxs4O6JSkPVPlUS8YmcNv9MPHJ5a4JpAkTVqKaIDQhrDRqov8QAeGDdAYlNnrWvhgQioC3mG3gu96IVtOpuVipcrODeGSrfhcr9mhHdEIIH5IyCWU9aOQY4URy62ImQ021-bCdE-CDohcUZTVYZP3LYwuxp-puAYq--FQjyokoONCAkvg3ak3maWcrN1ej-F5QYwuFHtl0KRPn8JnABuM-NssDr0JiYHJmJ8bkuhc-yiAoU9R6bBHYXaq1wnk76UsxIKIIh1gupDm0WVYlkymfA3LJxx07VgvytUOKksYzbvS7Mu6AQdFj1pYFh3oETglROdNnC53weF97BhMou2rbc9HCO-Tbb6T4pylkV1NoX5Nx2EdOySUIkiTBuqOmo38EuyMPEtCCsASV92Vq5v4L81mCBenc0bq-2ZcAm3kauyHQkZkVesw0KPeKxSTQGEyxcfgUrihFCUBG0Ogzl9FNBxrjPWDz0AO0BaTESYUNaHBIIvW7zuB4nnmXcjiYaGvTw7Bb1zvd5nun-ReWw3v84OHJ5gxq52lDthC6cAfibAQJ4e7h4bIBBEYUpoh0j6lymuLESq_xCV6LFGO5CKtrQbICGWc6XAbrh_mkNOAI3wOi-9Zf-EdrQDdj-rM3of6FWUAd08AT42ZENXQb9A4LAchz5fksAPju2ifjiNhpKKPSoCmkGew0qKbIVIai2vyAMxuWTDl0OYNVcvngJqO8h1kBbcAhEZAk1AeWetYOuAFJcoQY3yI6vxmWEXqp2Orz4iGqRwg_xrtFiCSmwDvt0yLJ13_LFnjhBFL6Ezfgr5XbY-1QAAKAJjbnN2u2j1sCi1N22t1gi00KBSI6K_r54zFgCRTqzEdpNBvnBlFoTlLgmwZdO8cJY8Xz2OMEUp-UK3KvE-Jx0YUV90c4hrzbMVlD9_u9AtveGGXD62s62eAAovqlq0w3GoYrpJQhCezhYym0r9pEOIFGIrqhFMHdVMtRbHHAcGI3xveXqtVPi_K5sbSIbOBJPgrWQ3s5-5qAIEG7hJsfZTuECdWGork-QqLKVOxqVsrwJ9FpEXXBj_dqET_yQnHghPTndIs0vKjDGoP3KE9zy90YZ_OrK8pKk0Njy-VbPCWLBtt21mPLrHBBO2QfaTWqwTbT2rIW4s1F20DdsvOxk_VWBQTIxC3owfjxt_lO0_psehYwWUkoedCCMZkVq-7lukVINZbDWA4i88iGn8g3mhNVcDtiJPNtFwrCzGAgwZN2NeSkKObOeoKbwqfJjUDLzokEY9jIWFtDwcI4pqgehmq2OMZnglvdMwNrpl_KFxjhBqCIgxew3rYYAwzwAmHzFIaV5QnG7HjjapJ1uS3iyLzYxXAp6UpbwZGjeGfYrle7RHhUpMHrrkEBaadMuQQJALfwaJAXkzzl63DuspBi0xbERecK5j2pAMhQt5j9YdtsaeWxCgmLWuHSUmhWqD1WaOZ4ViTQmY6BY1q2vtgnmUMWYZigNfG8JekRbkOC0JKk5zZWuHVcyMPI8cW7_cKInAkFsmmZGzti05L6B6icVAsmWEQGwtgeQxNodNxIBfmTNdBZUhYBb4bIkt9zghOhtoblBKjGvOjzaOKUe_7z6O-px8G7-RuLsAAh2md4XpVoZek3FkHrTcGgOaReuxZp83YQWCEF8I822uE-8eEyIiYmVEaL1-6aMmpxxmkMoBvNWNcj-egpWCEh8CpkOQ52SpdrZZSCuOyjAuvkvXsFOwNGoOshpvJPFluA75GAEMAX59cnanc_eQEK4HeoncJ5LDuHgXjZ6WnBMxLbJBhMljpl25U30VnOcLLr1p9LhjsydgPbc7XDnOdpI-7CQvCjZzY2thuUPrlwiKD0a06Cu9yLFaPLGTr5EiFxOFYonuUqR9hGV5Ka3TER2GeuSDWLQnVwWjBEQxzkahLt06Pw4VUURebKBf7K4QqyZus-0RsNibfCeAuIirCBggpWiu-HCvR4RxVSGC3hoBp2_ghmCKC2YGqhlBH9l3uwvbOzgbFkBgbGy9Sfa3JJ4kZJLeHqvHrE4lrbi3vCwiP7VeruBKhVSaeFo0ldk2A79ox59lnjRHHYs_aib_a78j7gkwOhR0Z1B1q0bikgCHBGSP1QiK7aBTHaOxsqAVMgS4VrLETItGsHJWKZz7MgiGbN-XRrcTVh6vN1kq_GmtEPIuIRkQbEZ_dYFSyIkctzhFhtUzt_aHUAudlparHD0vtH2o6FuTTJ93QSyi-ScytW_KtUOTJ2gesBtvCfdqrA7cJQY-B1Jzd0mjYfukN6sAaa0",
content: "Клубничка имба.",
ratings: {
story: 9,
visuals: 10,
performance: 8,
soundtrack: 10,
enjoyment: 9,
},
likes: 42,
comments: [],
createdAt: "2025-05-20T14:30:00.000Z",
spoiler: false,
},
{
id: "2",
user: {
id: id,
username: userData?.username || "User",
profilePicture: userData?.profilePicture,
isCritic: userData?.isCritic || false,
},
mediaId: "456",
mediaType: "tv",
mediaTitle: "Breaking Bad",
mediaPoster:
"https://kinopoisk-ru.clstorage.net/1L67Lp105/810449redVB/ZukoaopfTiUSOnVYJJxkXkBCJLdbT63V4ifXLUdXiOvBUJT00q4PPtjWHZCFj0FO-VB7fVQ-X8cuLV6nbKsGUeY6fb1ODBjxRakyjfFJ8dnFgPEm3t24ROVjAHuQnEThzdrIfV1kBzXAh44BQhhVG2lV-CvFqZgWKvHFt_yhHgZqKiBrA8hObtUi8VirlSOdmsHoOYWDIZS_IRymRZ4NqcjRAvfTZED9wIBKQFvSVBai2_MgSaAAFCO3yqr46FfIa-_jY8zHySlUq7IZYVmmEhiFqHbAxa3W9eMYIw8ZQq8N2g50G-jNes4GwcLbntbVqlK7I8Qjw96s9whqPahQhaYlou7AhkYk1yU5G23e4dJXhC4-TcspnTrsnuuNWQYkjJeEe9-ryHUNjstBldJbmmhdv-IPYEjT53VCqfyrEcnk7ekjCAcGIZKiNpyqXqfW3ongvgFPIFK5JFFnyBrFKUESAj7YbEQ2DUnBgFRX25WjlXsrhWnKWSaywun8J9sNq6Xsbc8PzO6UbXYSrlYkFNEM4HcJQ2fTfW2U68naQOoGmAb4naPL844BD0RYHJJTo9k44o2ix1ZtdMzrOibThewg6WyDisWl1WW62O-cL12TSq00RUMoEnyuFeyAEECqSx8BMlMnirvCB8jBkVceUesTOGIBocKY6bQPbzXo0M2qbqjmTE9B5lsnuJorV2XSUQujeQHFatmy4F_nDZLKLMdTQfbVK000js-ChROZFlkhknIqwCIHXKY2guVz5F9Er-aj6kxMSOBS4rfQo9lgmppEKfIOx2iUN-AYL0bTDySO2MfwmWLFOwtHBwlamRrT6J0_5MTmRpSpskdks--eyG4k5WKACkmvVi1y2ubUoBUdhS77SszoGvGg3-XI2sWsDFkJ_FOuDH0AyEbNlBZdWieROuhLb4jUofMM6fhoGAalISrkAMmLb5-ge5mrHSXd3sIrtw3F4BV5KFRgghaI5w8YSvWTawt1jgwAD1Mb0lpqUjcsDW1FE2Z1jOK-p11Apy9r78VJRuGSpHid5t3sFt_FKfcBg-FasORZ4sQeSCKNWA01kyyNP8tDT0dc0FoZ4JI9pYwozppi8odkNOMTj67u6qfAB80nFCo73KRTadjSBWV_AQKu2TVkXqtMWwJiSJDFOxQsRPdGCYGFHZ7elG9fs6nKqw_SKjWP5rTi2QArJalvjIDGLhBtcpHlnCMQn4ukOUHFZFA_7d6kj1pLbAhcQTdYJwrzD4GLRBNfVZOpVLeigavIF65wSOV-7p8GaCmrJkpJzO4WavDRZZ7tmBJHK7RNDaUedW3WqoqXSGDJUgsyGqDGO4kGAswb2B_dq1I-b8vvTtmht44kOiCfwSsgpK_IwQ_imKo53eFZL1EZD6W6CoylUXGgnacC00UsjVsDOhulBnQND8_GWxOWWSNQ8m3P6slebTyHYzTnVAjt563vCscAbt_l8ZJsWO_Qkovg9k3FoZ64KZguRlNK4MhXT3ASLwQ0iUOPzdCREhyhV35ix20CVmQ2QKt6q5kK5W_hrEfBjSFUK_bbqhfhUtfFbvFEjuUVe2xeJUDXwSAGn4930moNOwCOyUFc0NNQYZl-rY8vBtPlvwLnMygeT-ZvYaCAykPkUio3FWLW7xsWQ-Q_SkQu3v1gma9PkkgoRNKEdFLlALOIiAeKENORGS5UPaIM5gscojVJZ3nmHA6tamwgA0dHYJNnf5FqXytRGICusE3Lr9A9K1BqyF0G6cDTzvrX5E69gI4NRtiQHZ6pmLFkgC8FnCx7QuszIN9O5a0sbY_OhueT6n1TYRWmEh1K5nvCD2wcMKbQroxXyGRMXsQwEqPHv0ePwIBTGdyRa92xbwnmBtgq-4QuuufQh24l4WKKh80kUKF9lm9YqxCfBG8xCcDmHrSuE-cC2E8khBhFe1SrgPQKT8KN3J9fEeBT-GKFKkYbJLdCpXaunoFr5WsvhUlL6F9g8tWim2TVmwsjdstMZR68LBguTZqAoEkWi_8frIK0gM5DBxFaW5Vg2_HlQGkP2aY_T2wxqtFF761pJsTMSO2do7WSa5kv2JXK7r7DRGFTcGBfqIjZAOxJmQKw0mMLvsjCww4aGlOZ45y2a8tiB17kMwAu9SnVymypbafLCAgoX--y3OOZJdjQAm2_S4gmmvTuHqYOnsAswBMBsNvlCHqCC8XGl9SVU64ZMWfJ58_S5r5P73ap2EQu6OurzApBbF1ru1PjGW1QmQQutotDLxC0Z53mgFnH5E_QTzxd68y6gQcJjxCZ3RFhmbGlSaqI0up7ziH1oBcNruoo4ILGwO8eYL7e7dyhEpXJYLxGBG0beCncZ49ZwigAF838FCRKsw2BB82SXNWdJNB6oUPnwJkjNAKqMeOeBeohZqQFgQDiGyMyVSfV6xmYh-U2zU1ulHhkl6dNmw4qjBzNfhGjDzKJCo8KlFFSG6eX9mzBoQefZr2JL7yhFAEtIaEkTgrF4p4os5btFimU1wNueA1Nphy6aJdjjxfPYsDbgrrbLwp9gosCydPXF9wrkPeqyKMIkCu-gu1-6Z7OLm_iaAeFSCpdYX_WZRlkFdgPI36Bg2LYtKZWo4LZym0G2YK21-sGOgaKCgFaGlSabJE57MDpQpHq8AGpdGAYAmSmLOKESYAo1KT3VKuZYR7aBOA8RosnmbXoWSiOmQgqR5IHepFiR7pHT0rFExbUnSkXv6HNqcAZpjPHbrmsmIkkqekris5EJVSi-d4vXuNdH0Eh90YNJlBzqRwnRt_AY81cTv2QaEr2xUcKSBLZ0tiq0rboy-HAHuT2TyQ6q9aKpuiuJc7Ah2dTq_hdq9Rh3hgDaXZEw2dWcaHWboKfCWHBn049FOSN_wEPy04akhLSL9gwL8fuyFyk-IBi82sTBS8hrOeNCkRtlSD9m6lfoJvZTOnzCk-nkzkgn2ONHw6qzBeM_dSjBn3Ixg6Bl9Ad2qMU-2UA4MNWY8",
content:
"Одно из величайших телевизионных шоу, когда-либо созданных. Развитие персонажа Уолтера Уайта не имеет себе равных, показывая его превращение из мягкосердечного учителя химии в безжалостного наркобарона. Брайан Крэнстон демонстрирует потрясающую игру, которую поддерживает не менее впечатляющий актерский состав. Сценарий неизменно превосходен, с плотным сюжетом и значимыми дугами персонажей.",
ratings: {
story: 10,
visuals: 8,
performance: 10,
soundtrack: 7,
enjoyment: 10,
},
likes: 87,
comments: [{ id: "c1", user: "TVFan", content: "Completely agree!" }],
createdAt: "2025-06-12T09:15:00.000Z",
spoiler: true,
},
];
setUserReviews(mockReviews); const reviewsData = await getUserReviews(targetUserId);
}, [id, userData]); setReviews(reviewsData);
} catch (err) {
setError("Ошибка при загрузке профиля");
console.error("Error loading profile:", err);
} finally {
setLoading(false);
}
};
if (!userData) { loadProfile();
}, [userId, currentUser, navigate]);
if (loading) {
return ( return (
<div className="pt-20 flex justify-center items-center h-screen"> <div className="pt-20 container-custom py-12">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div> <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> </div>
); );
} }
// Calculate average ratings from reviews if (error) {
const calculateAverageRatings = () => { return (
if (!userReviews.length) return null; <div className="pt-20 container-custom py-12">
<div className="bg-status-error bg-opacity-20 text-status-error p-6 rounded-lg">
<h2 className="text-xl font-bold mb-2">Ошибка</h2>
<p>{error}</p>
</div>
</div>
);
}
const totals = { if (!profile) {
story: 0, return (
visuals: 0, <div className="pt-20 container-custom py-12">
performance: 0, <div className="bg-status-error bg-opacity-20 text-status-error p-6 rounded-lg">
soundtrack: 0, <h2 className="text-xl font-bold mb-2">Профиль не найден</h2>
enjoyment: 0, <p>Пользователь с таким ID не существует.</p>
}; </div>
</div>
);
}
userReviews.forEach((review) => { const isOwnProfile = currentUser?.id === profile.id;
Object.keys(totals).forEach((key) => {
totals[key] += review.ratings[key];
});
});
const averages = {};
Object.keys(totals).forEach((key) => {
averages[key] = totals[key] / userReviews.length;
});
return averages;
};
const averageRatings = calculateAverageRatings();
// Format join date
const joinDate = new Date(userData.createdAt).toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
day: "numeric",
});
return ( return (
<div className="pt-20"> <div className="pt-20 container-custom py-12">
{/* Profile Header */} <div className="bg-campfire-charcoal rounded-lg overflow-hidden">
<div className="bg-campfire-charcoal py-8"> {/* Заголовок профиля */}
<div className="container-custom"> <div className="relative h-48 bg-gradient-to-r from-campfire-amber to-campfire-ember">
<div className="flex flex-col md:flex-row items-center md:items-start gap-8"> {profile.profile_picture && (
{/* Profile Picture */}
<div className="relative">
<div className="w-32 h-32 rounded-full overflow-hidden bg-campfire-dark">
{userData.profilePicture ? (
<img <img
src={userData.profilePicture} src={profile.profile_picture}
alt={userData.username} alt={profile.username}
className="w-full h-full object-cover" className="absolute -bottom-16 left-8 w-32 h-32 rounded-full border-4 border-campfire-charcoal object-cover"
/> />
) : (
<div className="w-full h-full flex items-center justify-center text-campfire-ash">
{userData.username.substring(0, 1).toUpperCase()}
</div>
)} )}
</div> </div>
{isCurrentUser && ( <div className="pt-20 px-8 pb-8">
<button className="absolute bottom-0 right-0 bg-campfire-amber text-campfire-dark p-2 rounded-full"> <div className="flex justify-between items-start mb-6">
<FiEdit size={16} />
</button>
)}
</div>
{/* Profile Info */}
<div className="flex-1 text-center md:text-left">
<div className="flex flex-col md:flex-row md:items-center gap-4">
<h1 className="text-3xl font-bold">
{userData.username}
{userData.isCritic && (
<span className="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-campfire-amber text-campfire-dark rounded-full">
Резидент
</span>
)}
</h1>
{isCurrentUser && (
<div className="flex gap-2">
<Link to="/settings" className="btn-secondary text-sm">
<FiSettings className="mr-1" /> Настройки
</Link>
<button onClick={logout} className="btn-secondary text-sm">
<FiLogOut className="mr-1" /> Выйти
</button>
</div>
)}
</div>
<p className="text-campfire-ash my-4">
{userData.bio ||
`${userData.username} еще не написал ничего о себе.}`}
</p>
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-campfire-ash">
<div className="flex items-center">
<FiCalendar className="mr-1" />
<span>Участник с {joinDate}</span>
</div>
<div className="flex items-center">
<FiStar className="mr-1" />
<span>{userData.reviewCount} Рецензий</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Tabs Navigation */}
<div className="bg-campfire-dark">
<div className="container-custom">
<div className="flex overflow-x-auto space-x-8">
<button
onClick={() => setActiveTab("reviews")}
className={`py-4 font-medium ${
activeTab === "reviews"
? "text-campfire-amber border-b-2 border-campfire-amber"
: "text-campfire-ash hover:text-campfire-light"
}`}
>
Рецензии
</button>
<button
onClick={() => setActiveTab("stats")}
className={`py-4 font-medium ${
activeTab === "stats"
? "text-campfire-amber border-b-2 border-campfire-amber"
: "text-campfire-ash hover:text-campfire-light"
}`}
>
Статистика
</button>
<button
onClick={() => setActiveTab("favorites")}
className={`py-4 font-medium ${
activeTab === "favorites"
? "text-campfire-amber border-b-2 border-campfire-amber"
: "text-campfire-ash hover:text-campfire-light"
}`}
>
Избранное
</button>
</div>
</div>
</div>
{/* Tab Content */}
<div className="container-custom py-8">
{/* Reviews Tab */}
{activeTab === "reviews" && (
<div> <div>
<h2 className="text-2xl font-bold mb-6"> <h1 className="text-3xl font-bold mb-2">{profile.username}</h1>
Рецензии{" "} <p className="text-campfire-ash">
<span className="text-campfire-ash">({userReviews.length})</span> {profile.is_critic ? "Критик" : "Пользователь"}
</h2> </p>
{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> </div>
<Link {isOwnProfile && (
to={`/media/${review.mediaId}?type=${review.mediaType}`} <button
className="text-campfire-amber hover:text-campfire-ember" onClick={() => navigate('/settings')}
className="btn-secondary"
> >
{review.mediaTitle} Редактировать профиль
</Link> </button>
<span className="mx-2 text-campfire-ash"></span>
<span className="text-campfire-ash">
{review.mediaType === "movie" ? (
<FiFilm className="inline" />
) : (
<FiTv className="inline" />
)} )}
</span>
</div> </div>
<ReviewCard review={review} isDetailed={true} />
{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">
{reviews.map((review) => (
<div key={review.id} className="card p-4">
<div className="flex items-center mb-4">
<img
src={review.media.poster_url}
alt={review.media.title}
className="w-16 h-24 object-cover rounded-lg mr-4"
/>
<div>
<h3 className="font-bold">{review.media.title}</h3>
<p className="text-sm text-campfire-ash">
{review.media.type === 'movie' ? 'Фильм' :
review.media.type === 'series' ? 'Сериал' : 'Игра'}
</p>
</div>
</div>
<p className="text-sm mb-4 line-clamp-3">{review.content}</p>
<button
onClick={() => navigate(`/media/${review.media_id}`)}
className="btn-secondary w-full"
>
Читать полностью
</button>
</div> </div>
))} ))}
</div> </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,11 +1,13 @@
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: 'tv', TV: 'series',
ALL: 'all' // Добавлено GAME: 'game'
}; };
function isLocalStorageAvailable() { function isLocalStorageAvailable() {
@ -86,16 +88,60 @@ export async function getMediaById(id) {
} }
} }
export async function searchMedia(query) { export const searchMedia = async (query, type = 'movie') => {
try { try {
if (!query || !query.trim()) return []; const response = await fetch(
return await searchIMDb(query, mediaTypes.ALL); `${TMDB_BASE_URL}/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}&language=ru-RU`
);
if (!response.ok) {
throw new Error('Failed to fetch from TMDB');
}
const data = await response.json();
return data.results.map(item => ({
title: item.title || item.name,
description: item.overview,
poster_url: item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : null,
release_date: item.release_date || item.first_air_date,
type: type,
tmdb_id: item.id,
rating: item.vote_average
}));
} catch (error) { } catch (error) {
console.error('Error searching media:', error); console.error('Error searching media:', error);
return []; throw error;
} }
};
export const getMediaDetails = async (tmdbId, type = 'movie') => {
try {
const response = await fetch(
`${TMDB_BASE_URL}/${type}/${tmdbId}?api_key=${TMDB_API_KEY}&language=ru-RU`
);
if (!response.ok) {
throw new Error('Failed to fetch media details');
} }
const data = await response.json();
return {
title: data.title || data.name,
description: data.overview,
poster_url: data.poster_path ? `https://image.tmdb.org/t/p/w500${data.poster_path}` : null,
release_date: data.release_date || data.first_air_date,
type: type,
tmdb_id: data.id,
rating: data.vote_average,
genres: data.genres.map(g => g.name).join(', '),
runtime: data.runtime || data.episode_run_time?.[0] || null
};
} catch (error) {
console.error('Error fetching media details:', error);
throw error;
}
};
export async function addMedia(mediaData) { export async function addMedia(mediaData) {
try { try {
if (!isLocalStorageAvailable()) { if (!isLocalStorageAvailable()) {
@ -129,3 +175,34 @@ 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,6 +11,8 @@ 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,
@ -18,20 +20,30 @@ 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')
.upsert({ .insert({
id: data.user.id, id: data.user.id,
email, email,
username, username,
role: 'user' role: 'user',
created_at: new Date().toISOString()
}); });
if (profileError) throw profileError; if (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) => {
@ -57,7 +69,7 @@ export const getUserProfile = async (userId) => {
.from('users') .from('users')
.select('*') .select('*')
.eq('id', userId) .eq('id', userId)
.single(); .maybeSingle();
if (error) throw error; if (error) throw error;
return data; return data;
@ -65,14 +77,33 @@ 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(mediaData) .insert({
...mediaData,
created_by: user.id,
created_at: new Date().toISOString(),
is_published: false
})
.select() .select()
.single(); .single();
if (error) throw error; if (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) => {
@ -93,22 +124,35 @@ export const getMediaById = async (id) => {
return data; return data;
}; };
export const listMedia = async (type = null, page = 1, limit = 10) => { export const listMedia = async (type = null, page = 1, limit = 20) => {
try {
let query = supabase let query = supabase
.from('media') .from('media')
.select('*') .select('*', { count: 'exact' })
.eq('is_published', true)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (type) query = query.eq('type', type); if (type) {
query = query.eq('type', type);
}
const { data, error } = await query.range( const { data, error, count } = await query
(page - 1) * limit, .range((page - 1) * limit, page * limit - 1);
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

72
supabase/config.toml Normal file
View File

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

View File

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