feat: paginate workshops and videos pages
This commit is contained in:
@@ -15,7 +15,9 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous" defer>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
482
frontend-plataforma-tutoriais/package-lock.json
generated
482
frontend-plataforma-tutoriais/package-lock.json
generated
@@ -8,7 +8,10 @@
|
||||
"name": "frontend-plataforma-tutoriais",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@n8n/chat": "^1.21.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"motion": "^12.40.0",
|
||||
"plyr": "^3.8.4",
|
||||
"plyr-react": "^6.0.0",
|
||||
"react": "^19.2.4",
|
||||
@@ -170,7 +173,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -180,7 +182,6 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -211,10 +212,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||
"dev": true,
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
@@ -273,7 +273,6 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@@ -642,7 +641,6 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -656,6 +654,27 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/chat": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@n8n/chat/-/chat-1.21.0.tgz",
|
||||
"integrity": "sha512-7gHwci11kqCjNoPPaw4yopyJY0WdFZrGPsRedlZOdyDcVRsZP7euui1cHmeJQZyp4IcDN9RR8a+AhXFax3952A==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"@n8n/design-system": "2.21.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"highlight.js": "11.8.0",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"uuid": "10.0.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-markdown-render": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/design-system": {
|
||||
"version": "2.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@n8n/design-system/-/design-system-2.21.0.tgz",
|
||||
"integrity": "sha512-pTXjnoH9eS8kTw2s7xGB1TofckVvIPkdyVV5bgSh7FvbnSclKQoNP512IiR5XrmNEPcufZ2tGs6XK1k2ny1jwA==",
|
||||
"license": "SEE LICENSE IN LICENSE.md"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
@@ -1123,6 +1142,12 @@
|
||||
"integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
|
||||
@@ -1444,6 +1469,194 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
|
||||
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@vue/shared": "3.5.34",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
|
||||
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
|
||||
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@vue/compiler-core": "3.5.34",
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/compiler-ssr": "3.5.34",
|
||||
"@vue/shared": "3.5.34",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.14",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
|
||||
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
|
||||
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
|
||||
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
|
||||
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.34",
|
||||
"@vue/runtime-core": "3.5.34",
|
||||
"@vue/shared": "3.5.34",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
|
||||
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
|
||||
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -1504,7 +1717,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
@@ -1558,6 +1770,22 @@
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap-icons": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
|
||||
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
@@ -1822,6 +2050,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -2019,6 +2259,12 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -2119,6 +2365,33 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
|
||||
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.40.0",
|
||||
"motion-utils": "^12.39.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2197,6 +2470,15 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.8.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
|
||||
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2624,6 +2906,15 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/loadjs": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
|
||||
@@ -2675,6 +2966,56 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it-link-attributes": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it-link-attributes/-/markdown-it-link-attributes-4.0.1.tgz",
|
||||
"integrity": "sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -2688,6 +3029,47 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz",
|
||||
"integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.40.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
|
||||
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.39.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2696,10 +3078,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2824,7 +3205,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -2870,10 +3250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"dev": true,
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2890,7 +3269,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2942,6 +3321,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
@@ -3235,7 +3623,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3336,7 +3723,7 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -3370,6 +3757,12 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uncontrollable": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
|
||||
@@ -3448,6 +3841,20 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
||||
@@ -3526,6 +3933,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
|
||||
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/compiler-sfc": "3.5.34",
|
||||
"@vue/runtime-dom": "3.5.34",
|
||||
"@vue/server-renderer": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-markdown-render": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-markdown-render/-/vue-markdown-render-2.3.0.tgz",
|
||||
"integrity": "sha512-ZWVVKba8t0tKBlaUGaWmNynIk38gE7Bt3psC/iN2NsqpdGY15VGfBeBvF0A8cEmwHnjNVJo2IzUUqkhhfldhtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/chat": "^1.21.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"motion": "^12.40.0",
|
||||
"plyr": "^3.8.4",
|
||||
"plyr-react": "^6.0.0",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
import '@n8n/chat/style.css';
|
||||
import { createChat } from '@n8n/chat';
|
||||
import './n8n-chat-theme.css';
|
||||
|
||||
export default function N8nChat() {
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
document.head.appendChild(style);
|
||||
|
||||
const app = createChat({
|
||||
webhookUrl: 'https://n8n.dev.livetech.pt/webhook/b29d21a8-9fbd-4cdd-810f-30470bffe32e',
|
||||
target: '#n8n-chat',
|
||||
mode: 'window',
|
||||
initialMessages: ['Olá! 👋 Como posso ajudar?'],
|
||||
i18n: {
|
||||
en: {
|
||||
title: 'Assistente AI',
|
||||
subtitle: '',
|
||||
footer: '',
|
||||
getStarted: 'Nova conversa',
|
||||
inputPlaceholder: 'Escreva a sua pergunta...',
|
||||
closeButtonTooltip: 'Fechar',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
app.unmount();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="n8n-chat"
|
||||
style={{ color: 'var(--text-primary-color)', textAlign: 'start'}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
:root {
|
||||
--chat--header--background: var(--primary-color);
|
||||
--chat--header--color: var(--text-white);
|
||||
--chat--header--title-size: 22px;
|
||||
--chat--body--background: var(--bg-white);
|
||||
--chat--color-primary: var(--primary-color);
|
||||
--chat--color-primary-shade-50: var(--primary-color-dark);
|
||||
--chat--message--user--background: var(--bg-primary-color-opacity);
|
||||
--chat--message--user--color: var(--text-primary-color);
|
||||
--chat--message--bot--background: var(--bg-grey);
|
||||
--chat--message--bot--color: var(--text-black);
|
||||
--chat--input--border-color-active: var(--shadow-primary);
|
||||
--chat--color-disabled: var(--primary-color);
|
||||
--chat--input--text-color: var(--text-primary-color);
|
||||
--chat--heading--font-size: 22px;
|
||||
--chat--border-radius: var(--border-radius);
|
||||
--chat--message--border-radius: var(--border-radius);
|
||||
--chat--message--border-color: var(--shadow-primary);
|
||||
--chat--input--send--button--color: var(--primary-color);
|
||||
--chat--toggle--background: var(--primary-color);
|
||||
--chat--toggle--border: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.chat-window-wrapper .chat-window-toggle {
|
||||
border: var(--chat--toggle--border) !important;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-window-wrapper .chat-window-toggle:hover {
|
||||
background-color: var(--bg-grey) !important;
|
||||
color: var(--text-primary-color) !important;
|
||||
}
|
||||
|
||||
.chat-layout .chat-header h1 {
|
||||
margin: 0 !important;
|
||||
letter-spacing: 1px !important;
|
||||
}
|
||||
|
||||
.chat-inputs textarea {
|
||||
color: var(--text-black) !important;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.chat-message a {
|
||||
color: var(--primary-color) !important;
|
||||
text-decoration: underline !important;
|
||||
pointer-events: auto !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
@@ -8,14 +8,17 @@ import type { Video } from "../../types";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { useGetVideos } from "../../hooks/useGetVideos";
|
||||
import { useGetWorkshops } from "../../hooks/useGetWorkshops";
|
||||
import { usePreloadImages } from "../../hooks/usePreloadImages";
|
||||
import { useGetVideosLength } from "../../hooks/useGetVideosLength";
|
||||
import { useGetVideosSearch } from "../../hooks/useGetVideosSearch";
|
||||
import { PiCheckCircleFill } from "react-icons/pi";
|
||||
import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function Header() {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchCompleted, setSearchCompleted] = useState(false);
|
||||
const [videosSearched, setVideosSearched] = useState<Video[]>([]);
|
||||
@@ -23,18 +26,37 @@ export default function Header() {
|
||||
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
|
||||
const isAdmin = user.role_id === 1;
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const { getVideos } = useGetVideos();
|
||||
const { getWorkshops } = useGetWorkshops();
|
||||
const { preloadImages } = usePreloadImages();
|
||||
const { getVideosLength } = useGetVideosLength();
|
||||
const { getVideosSearch } = useGetVideosSearch();
|
||||
const { getWorkshopsSearch } = useGetWorkshopsSearch();
|
||||
const [videosStats, setVideosStats] = useState({
|
||||
videos: 0,
|
||||
videosWatched: 0
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) return;
|
||||
|
||||
const fetchProgressVideos = async () => {
|
||||
const videosLengthData = await getVideosLength();
|
||||
if ("videos" in videosLengthData) {
|
||||
setVideosStats(videosLengthData as { videos: number, videosWatched: number });
|
||||
}
|
||||
};
|
||||
fetchProgressVideos();
|
||||
}, []);
|
||||
|
||||
const handleCloseMenu = () => setShowMenu(false);
|
||||
const handleShowMenu = () => setShowMenu(true);
|
||||
const handleCloseSearch = () => setShowSearch(false);
|
||||
const handleShowSearch = () => setShowSearch(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (debouncedSearch.trim() === "") {
|
||||
setVideosSearched([]);
|
||||
setWorkshopsSearched([]);
|
||||
@@ -47,40 +69,42 @@ export default function Header() {
|
||||
setSearchCompleted(false);
|
||||
|
||||
const fetchAll = async () => {
|
||||
|
||||
try {
|
||||
const [videosData, workshopsData] = await Promise.all([
|
||||
getVideos(debouncedSearch),
|
||||
getWorkshops(debouncedSearch),
|
||||
|
||||
const [videosData, videosSearchedData, workshopsData] = await Promise.all([
|
||||
getVideos({ page: 1 }),
|
||||
getVideosSearch(debouncedSearch),
|
||||
getWorkshopsSearch(debouncedSearch),
|
||||
]);
|
||||
|
||||
if (Array.isArray(videosData)) {
|
||||
setVideosSearched(videosData);
|
||||
if ("videos" in videosSearchedData) {
|
||||
setVideosSearched(videosSearchedData.videos);
|
||||
} else {
|
||||
setVideosSearched([]);
|
||||
}
|
||||
|
||||
if (Array.isArray(workshopsData)) {
|
||||
setWorkshopsSearched(workshopsData);
|
||||
if ("workshops" in workshopsData) {
|
||||
setWorkshopsSearched(workshopsData.workshops);
|
||||
} else {
|
||||
setWorkshopsSearched([]);
|
||||
}
|
||||
|
||||
await preloadImages([
|
||||
...(videosSearched as Video[]).map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
|
||||
...(workshopsSearched as Workshop[]).map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
|
||||
]);
|
||||
|
||||
setSearchCompleted(true);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
setVideosSearched([]);
|
||||
setWorkshopsSearched([]);
|
||||
setSearchCompleted(true);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
|
||||
}, [debouncedSearch]);
|
||||
|
||||
function handleSearch(event: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -126,19 +150,21 @@ export default function Header() {
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/workshops"> <LuGraduationCap className="me-2" size={24} />Workshops</NavLink>
|
||||
</li>
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/admin/users"> <LuUsers className="me-2" size={24} />Utilizadores</NavLink>
|
||||
</li>
|
||||
{isAdmin && (
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/admin/users"> <LuUsers className="me-2" size={24} />Utilizadores</NavLink>
|
||||
</li>
|
||||
)}
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/contactos"> <LuMail className="me-2" size={24} />Contactos</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{!isAdmin && (
|
||||
{!isAdmin && videosStats.videos > 0 && (
|
||||
<div className="d-flex d-sm-none justify-content-center">
|
||||
<span className="fw-semibold">Vídeos assistidos</span>
|
||||
<span className={`${styles.badge} badge align-content-center ms-3`}>0/{videos.filter((video) => video.is_active).length}</span>
|
||||
<span className={`${styles.badge} badge align-content-center ms-3`}>{videosStats.videosWatched}/{videosStats.videos}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -151,14 +177,12 @@ export default function Header() {
|
||||
|
||||
<nav className="navbar px-3">
|
||||
<div className={styles.headerRight}>
|
||||
{!isAdmin && (
|
||||
{!isAdmin && videosStats.videos > 0 && (
|
||||
<>
|
||||
{videos.length > 0 && (
|
||||
<div className="d-none d-sm-flex align-items-center gap-1 text-muted">
|
||||
<span className="fw-semibold">Vídeos assistidos</span>
|
||||
<span className={`${styles.badge} badge align-content-center`}>0/{videos.filter((video) => video.is_active).length}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-none d-sm-flex align-items-center gap-1 text-muted">
|
||||
<span className="fw-semibold">Vídeos assistidos</span>
|
||||
<span className={`${styles.badge} badge align-content-center`}>{videosStats.videosWatched}/{videosStats.videos}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -206,11 +230,17 @@ export default function Header() {
|
||||
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
|
||||
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black pb-2`} key={video.id} onClick={handleCloseSearch}>
|
||||
<div className={`${styles.boxVideo} position-relative`} >
|
||||
<img className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
|
||||
<img
|
||||
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
|
||||
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
|
||||
alt={video.title}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
|
||||
<h2 className={`${styles.titleVideo} mb-1`}>{video.title}</h2>
|
||||
<span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span>
|
||||
</div>
|
||||
{video.watched && <span className="d-block badge text-success text-start fs-6 position-absolute top-0 start-0 bg-success-subtle text-success px-3 py-2" style={{ borderRadius: "0 0 var(--border-radius) 0" }}><PiCheckCircleFill className="mb-1 me-1" /> Visto</span>}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -232,8 +262,14 @@ export default function Header() {
|
||||
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 mt-0 p-2 position-relative">
|
||||
<div className={`${styles.boxWorkshop} text-start pb-3`}>
|
||||
<Link to={`/workshop/${workshop.id}`} className="text-decoration-none text-black" onClick={handleCloseSearch}>
|
||||
<div className="position-relative">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
|
||||
<div className={`${styles.thumbnailWorkshop} position-relative`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={styles.thumbnailWorkshop}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
||||
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
||||
<p className={`${styles.timeWorkshop}`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
||||
@@ -241,7 +277,6 @@ export default function Header() {
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
|
||||
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
|
||||
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3">
|
||||
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
|
||||
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
|
||||
@@ -274,14 +309,25 @@ export default function Header() {
|
||||
</Offcanvas>
|
||||
</div>
|
||||
|
||||
<div className="btn-group">
|
||||
<button type="button" className={`${styles.headerDropdownToggle} btn dropdown-toggle`} data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div className="btn-group" onMouseEnter={() => setShowDropdown(true)} onMouseLeave={() => setShowDropdown(false)}>
|
||||
<button type="button" className={`${styles.headerDropdownToggle} btn dropdown-toggle`} >
|
||||
<LuUser size={30} />
|
||||
</button>
|
||||
<ul className="dropdown-menu dropdown-menu-end">
|
||||
<li><Link className="dropdown-item" to="/profile"><LuCircleUser className="mb-1 me-2" size={24} />A minha conta</Link></li>
|
||||
<li><a className="dropdown-item mt-2" href="/logout" style={{ color: "var(--text-primary-color)" }}> <LuLogOut className="mb-1 me-2" size={24} />Sair</a></li>
|
||||
</ul>
|
||||
|
||||
<AnimatePresence>
|
||||
{showDropdown && (
|
||||
<motion.ul
|
||||
initial={{ opacity: 0, y: -10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="dropdown-menu dropdown-menu-end"
|
||||
style={{ display: "block", zIndex: 2000, right: 0, top: "100%", marginTop: "0.25rem" }}>
|
||||
<li><Link className="dropdown-item" to="/profile"><LuCircleUser className="mb-1 me-2" size={24} />A minha conta</Link></li>
|
||||
<li><a className="dropdown-item mt-2" href="/logout" style={{ color: "var(--text-primary-color)" }}> <LuLogOut className="mb-1 me-2" size={24} />Sair</a></li>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -114,12 +114,12 @@
|
||||
width: 200px;
|
||||
text-decoration: none;
|
||||
color: var(--text-white);
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-gradient);
|
||||
box-shadow: var(--shadow-primary);
|
||||
border-radius: var(--border-radius-button);
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
@@ -174,6 +174,9 @@
|
||||
}
|
||||
|
||||
.boxVideo {
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -181,7 +184,12 @@
|
||||
}
|
||||
|
||||
.boxVideo::after {
|
||||
content: '';
|
||||
content: '\F4F4';
|
||||
color: var(--bg-grey);
|
||||
font-size: 4rem;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
font-family: 'bootstrap-icons';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -189,6 +197,11 @@
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
|
||||
z-index: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.boxVideo:hover::after {
|
||||
color: var(--bg-primary-color);
|
||||
}
|
||||
|
||||
.boxVideoInfo{
|
||||
@@ -268,12 +281,17 @@
|
||||
}
|
||||
|
||||
.thumbnailWorkshop{
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon{
|
||||
@@ -284,6 +302,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
text-decoration: none;
|
||||
color: var(--text-white);
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--border-radius-button);
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
import type { ApiErrorResponse, Video } from "../types";
|
||||
|
||||
type GetVideosParams = {
|
||||
page?: number;
|
||||
search?: string;
|
||||
category?: string;
|
||||
status?: "active" | "inactive";
|
||||
watched?: 0 | 1;
|
||||
};
|
||||
|
||||
export function useGetVideos() {
|
||||
async function getVideos(searchQuery?: string) {
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/videos?search=${encodeURIComponent(searchQuery ?? "")}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
});
|
||||
async function getVideos(params: GetVideosParams) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (params.page !== undefined) query.append("page", params.page.toString());
|
||||
if (params.search) query.append("search", params.search);
|
||||
if (params.category) query.append("category", params.category);
|
||||
if (params.status) query.append("status", params.status);
|
||||
if (params.watched !== undefined) query.append("watched", params.watched.toString());
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/videos?${query.toString()}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return data.data as Video[];
|
||||
return {
|
||||
videos: data.data as Video[],
|
||||
meta: data.meta
|
||||
};
|
||||
} else {
|
||||
return (data as ApiErrorResponse);
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ApiErrorResponse } from "../types";
|
||||
|
||||
export function useGetVideosLength() {
|
||||
async function getVideosLength() {
|
||||
try {
|
||||
const response = await fetch("http://127.0.0.1:8000/api/videos-length", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
videos: data.data.videos,
|
||||
videosWatched: data.data.videosWatched,
|
||||
};
|
||||
}
|
||||
|
||||
return data as ApiErrorResponse;
|
||||
|
||||
} catch (error) {
|
||||
return error as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return { getVideosLength };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ApiErrorResponse, Video } from "../types";
|
||||
|
||||
export function useGetVideosSearch() {
|
||||
async function getVideosSearch(searchQuery: string = "") {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:8000/api/videos-search?search=${encodeURIComponent(searchQuery)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
videos: data.data as Video[],
|
||||
meta: data.meta
|
||||
};
|
||||
} else {
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return { getVideosSearch };
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { ApiErrorResponse, Workshop } from "../types";
|
||||
|
||||
type GetWorkshopsParams = {
|
||||
search?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
};
|
||||
|
||||
export function useGetWorkshops() {
|
||||
async function getWorkshops(searchQuery?: string) {
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/workshops?search=${encodeURIComponent(searchQuery ?? "")}`, {
|
||||
async function getWorkshops(params: GetWorkshopsParams) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (params.search) query.append("search", params.search);
|
||||
if (params.status) query.append("status", params.status);
|
||||
if (params.page) query.append("page", params.page.toString());
|
||||
if (params.per_page) query.append("per_page", params.per_page.toString());
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/workshops?${query.toString()}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
@@ -14,7 +28,10 @@ export function useGetWorkshops() {
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return data.data as Workshop[];
|
||||
return {
|
||||
workshops: data.data as Workshop[],
|
||||
meta: data.meta
|
||||
};
|
||||
} else {
|
||||
return (data as ApiErrorResponse);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ApiErrorResponse } from "../types";
|
||||
|
||||
export function useGetWorkshopsLength() {
|
||||
async function getWorkshopsLength() {
|
||||
try {
|
||||
const response = await fetch("http://127.0.0.1:8000/api/workshops-length", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
workshops: data.data.workshops,
|
||||
workshopsInscribed: data.data.workshopsInscribed,
|
||||
};
|
||||
}
|
||||
|
||||
return data as ApiErrorResponse;
|
||||
|
||||
} catch (error) {
|
||||
return error as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return { getWorkshopsLength };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ApiErrorResponse, Workshop } from "../types";
|
||||
|
||||
export function useGetWorkshopsSearch() {
|
||||
async function getWorkshopsSearch(searchQuery: string = "") {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:8000/api/workshops-search?search=${encodeURIComponent(searchQuery)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
workshops: data.data as Workshop[],
|
||||
meta: data.meta
|
||||
};
|
||||
} else {
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return { getWorkshopsSearch };
|
||||
}
|
||||
23
frontend-plataforma-tutoriais/src/hooks/useNextVideos.ts
Normal file
23
frontend-plataforma-tutoriais/src/hooks/useNextVideos.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ApiErrorResponse, Video } from "../types";
|
||||
|
||||
export function useNextVideos() {
|
||||
async function getNextVideos() {
|
||||
const response = await fetch("http://127.0.0.1:8000/api/next-videos", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
return {
|
||||
videos: data.data as Video[],
|
||||
};
|
||||
} else {
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
return { getNextVideos };
|
||||
}
|
||||
23
frontend-plataforma-tutoriais/src/hooks/useNextWorkshops.ts
Normal file
23
frontend-plataforma-tutoriais/src/hooks/useNextWorkshops.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ApiErrorResponse, NextWorkshopsResponse } from "../types";
|
||||
|
||||
export function useNextWorkshops() {
|
||||
async function getNextWorkshops() {
|
||||
const response = await fetch("http://127.0.0.1:8000/api/next-workshops", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
return {
|
||||
workshops: data.data as NextWorkshopsResponse[],
|
||||
};
|
||||
} else {
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
return { getNextWorkshops };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function usePreloadImages() {
|
||||
const preloadImages = useCallback ((urls: string[]): Promise<void[]> => {
|
||||
return Promise.all(
|
||||
urls.map(url => new Promise<void>((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => resolve();
|
||||
img.src = url;
|
||||
}))
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { preloadImages };
|
||||
}
|
||||
|
||||
29
frontend-plataforma-tutoriais/src/hooks/useVideoWatch.ts
Normal file
29
frontend-plataforma-tutoriais/src/hooks/useVideoWatch.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
export function useVideoWatch(videoId: number, initialWatched: boolean) {
|
||||
const [watched, setWatched] = useState(initialWatched);
|
||||
const alreadySent = useRef(false);
|
||||
|
||||
const markAsWatched = async () => {
|
||||
if (alreadySent.current || watched ) return;
|
||||
|
||||
alreadySent.current = true;
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:8000/api/video/${videoId}/watch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
});
|
||||
|
||||
setWatched(true);
|
||||
} catch (error) {
|
||||
console.error("Erro ao marcar o vídeo como assistido: ", error);
|
||||
alreadySent.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { watched, markAsWatched };
|
||||
}
|
||||
@@ -1,157 +1,164 @@
|
||||
:root {
|
||||
--primary-color: #B20112;
|
||||
--secondary-color: #C4534A;
|
||||
--tertiary-color: #0054B0;
|
||||
--success-color: #08a35d;
|
||||
--neutral-color: #8A716E;
|
||||
--primary-contrast-color: #410002;
|
||||
--primary-color: #B20112;
|
||||
--secondary-color: #C4534A;
|
||||
--tertiary-color: #0054B0;
|
||||
--success-color: #08a35d;
|
||||
--neutral-color: #8A716E;
|
||||
--primary-contrast-color: #410002;
|
||||
|
||||
--text-primary-color: var(--primary-color);
|
||||
--text-secondary-color: var(--secondary-color);
|
||||
--text-tertiary-color: var(--tertiary-color);
|
||||
--text-neutral-color: var(--neutral-color);
|
||||
--text-primary-contrast-color: var(--primary-contrast-color);
|
||||
--text-white: #ffffff;
|
||||
--text-black: #000000;
|
||||
--text-primary-color: var(--primary-color);
|
||||
--text-secondary-color: var(--secondary-color);
|
||||
--text-tertiary-color: var(--tertiary-color);
|
||||
--text-neutral-color: var(--neutral-color);
|
||||
--text-primary-contrast-color: var(--primary-contrast-color);
|
||||
--text-white: #ffffff;
|
||||
--text-black: #000000;
|
||||
|
||||
--size-font-title: 2.2rem;
|
||||
--size-font-subtitle: 1.7rem;
|
||||
--size-font-text: 1.2rem;
|
||||
--size-font-small: 1rem;
|
||||
--size-font-title: clamp(1.8rem, calc(1.607vw + 0.629rem), 2.2rem);
|
||||
--size-font-subtitle: clamp(1.55rem, calc(1.116vw + 0.614rem), 1.7rem);
|
||||
--size-font-text: clamp(1.2rem, calc(0.394vw + 0.811rem), 1.2rem);
|
||||
--size-font-small: clamp(1rem, calc(0.295vw + 0.708rem), 1rem);
|
||||
|
||||
--border-radius: 15px;
|
||||
--border-radius-input: 5px;
|
||||
--border-radius-button: 10px;
|
||||
--border-primary-color: var(--primary-color);
|
||||
--border-secondary-color: var(--secondary-color);
|
||||
--border-tertiary-color: var(--tertiary-color);
|
||||
--border-neutral-color: var(--neutral-color);
|
||||
|
||||
|
||||
--border-radius: 15px;
|
||||
--border-radius-input: 5px;
|
||||
--border-radius-button: 10px;
|
||||
--border-primary-color: var(--primary-color);
|
||||
--border-secondary-color: var(--secondary-color);
|
||||
--border-tertiary-color: var(--tertiary-color);
|
||||
--border-neutral-color: var(--neutral-color);
|
||||
--bg-white: #ffffff;
|
||||
--bg-grey: #F3F3F3;
|
||||
--bg-primary-color: var(--primary-color);
|
||||
--bg-primary-color-opacity: #FFEDEA;
|
||||
--bg-secondary-color: var(--secondary-color);
|
||||
--bg-tertiary-color: var(--tertiary-color);
|
||||
--bg-tertiary-color-opacity: #CFE2FF;
|
||||
--bg-success-color-opacity: #D1E7DD;
|
||||
--bg-neutral-color: var(--neutral-color);
|
||||
--bg-gradient: linear-gradient(135deg, #b20112 0%, #8e010e 100%);
|
||||
|
||||
--bg-white: #ffffff;
|
||||
--bg-grey: #F3F3F3;
|
||||
--bg-primary-color: var(--primary-color);
|
||||
--bg-primary-color-opacity: #FFEDEA;
|
||||
--bg-secondary-color: var(--secondary-color);
|
||||
--bg-tertiary-color: var(--tertiary-color);
|
||||
--bg-tertiary-color-opacity: #CFE2FF;
|
||||
--bg-success-color-opacity: #D1E7DD;
|
||||
--bg-neutral-color: var(--neutral-color);
|
||||
--bg-gradient: linear-gradient(135deg, #b20112 0%, #8e010e 100%);
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:rgba(0, 0, 0, 0.1) 0 10px 15px -3px,
|
||||
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
--shadow-primary:0 4px 10px rgba(178, 1, 18, 0.4),
|
||||
0 2px 6px rgba(142, 1, 14, 0.3);
|
||||
;
|
||||
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
--shadow-primary:0 4px 10px rgba(178, 1, 18, 0.4), 0 2px 6px rgba(142, 1, 14, 0.3);;
|
||||
--font-family: 'Manrope',
|
||||
sans-serif;
|
||||
|
||||
--font-family: 'Manrope', sans-serif;
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-white);
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-white);
|
||||
}
|
||||
|
||||
*{
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-family);
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: var(--font-family);
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
@@ -161,7 +168,7 @@ html body .pagination .page-item.active .page-link {
|
||||
color: var(--text-primary-color) !important;
|
||||
}
|
||||
|
||||
.page-link{
|
||||
.page-link {
|
||||
color: var(--text-neutral-color) !important;
|
||||
}
|
||||
|
||||
@@ -169,10 +176,7 @@ input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
button:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border: none;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router'
|
||||
import {router} from './routes'
|
||||
import './index.css'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link, Outlet, useNavigate } from "react-router";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
import styles from "./styles.module.css";
|
||||
import Header from "../../components/header";
|
||||
import Sidebar from "../../components/sidebar";
|
||||
import Footer from "../../components/footer";
|
||||
import { useEffect } from "react";
|
||||
import Chatbot from "../../components/chatbot";
|
||||
|
||||
export default function ProtectedLayout() {
|
||||
|
||||
@@ -29,8 +29,11 @@ export default function ProtectedLayout() {
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<Chatbot />
|
||||
{/* <Footer /> */}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function CreateVideo() {
|
||||
const [checkingRole, setCheckingRole] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [order, setOrder] = useState<number>(0);
|
||||
|
||||
setTimeout(() => {
|
||||
setCheckingRole(false);
|
||||
@@ -68,6 +69,7 @@ export default function CreateVideo() {
|
||||
formData.append("url", videoFile);
|
||||
formData.append("thumbnail", thumbnailFile);
|
||||
formData.append("tags", tags);
|
||||
formData.append("order", order.toString());
|
||||
|
||||
category_ids.forEach(id => {
|
||||
formData.append("category_ids[]", id);
|
||||
@@ -112,6 +114,7 @@ export default function CreateVideo() {
|
||||
setVideoFile(null);
|
||||
setThumbnailFile(null);
|
||||
setTags("");
|
||||
setOrder(0);
|
||||
setCategoryIds([]);
|
||||
setCreating(false);
|
||||
Swal.fire({
|
||||
@@ -248,6 +251,10 @@ export default function CreateVideo() {
|
||||
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-sm-2 px-1 text-start">
|
||||
<label className="form-label fw-bold" htmlFor="order">Ordem</label>
|
||||
<input type="number" className="form-control py-2 text-truncate" id="order" name="order" placeholder="Insira a ordem do vídeo" value={order} onChange={(e) => setOrder(parseInt(e.target.value, 10))} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start">
|
||||
<div className="d-flex">
|
||||
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
|
||||
@@ -110,22 +110,22 @@ export default function CreateWorkshop() {
|
||||
<h1 className={styles.title}>Adicionar Workshop</h1>
|
||||
<div className="row mx-auto g-4">
|
||||
<div className="col-12 px-1 text-start">
|
||||
<label className="form-label fw-bold" htmlFor="title">Título</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="title">Título</label>
|
||||
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" placeholder="Insira o título do workshop" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start">
|
||||
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="description">Descrição</label>
|
||||
<textarea rows={5} className="form-control py-2" id="description" name="description" placeholder="Insira uma descrição para o workshop" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start">
|
||||
<label className="form-label fw-bold" htmlFor="image">Adicionar imagem</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="image">Adicionar imagem</label>
|
||||
<div className="position-relative">
|
||||
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1" /> {image ? image.name : "Carregar imagem do workshop"} </span>
|
||||
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="image" name="image" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setImage(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
|
||||
<label className="form-label fw-bold mb-2" htmlFor="date">Data</label>
|
||||
<label className={`${styles.label} form-label fw-bold mb-2`} htmlFor="date">Data</label>
|
||||
<DatePicker
|
||||
selected={date}
|
||||
onChange={(d: Date | null) => setDate(d)}
|
||||
@@ -137,7 +137,7 @@ export default function CreateWorkshop() {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
|
||||
<label className="form-label fw-bold" htmlFor="time_start">Hora de início</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_start">Hora de início</label>
|
||||
<DatePicker
|
||||
selected={time_start}
|
||||
onChange={(t: Date | null) => setTimeStart(t)}
|
||||
@@ -158,7 +158,7 @@ export default function CreateWorkshop() {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
|
||||
<label className="form-label fw-bold" htmlFor="time_end">Hora de término</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_end">Hora de término</label>
|
||||
<DatePicker
|
||||
selected={time_end}
|
||||
onChange={(t: Date | null) => setTimeEnd(t)}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
@@ -17,6 +18,12 @@
|
||||
font-size: var(--size-font-title);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--size-font-small);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.inputFiles{
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function editVideo() {
|
||||
const [category_ids, setCategoryIds] = useState<string[]>([]);
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||
const [isActive, setIsActive] = useState<boolean>();
|
||||
const [order, setOrder] = useState<number>();
|
||||
const [error, setError] = useState<ApiErrorResponse | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -24,8 +25,10 @@ export default function editVideo() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
console.log(video);
|
||||
setCategoryIds(video.categories?.map((category) => category.id.toString()) ?? []);
|
||||
setIsActive(Boolean(video.is_active));
|
||||
setOrder(video.order);
|
||||
}, [video]);
|
||||
|
||||
async function getVideo() {
|
||||
@@ -58,7 +61,7 @@ export default function editVideo() {
|
||||
}
|
||||
|
||||
async function destroy(id: number) {
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/delete-video/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
@@ -250,93 +253,93 @@ export default function editVideo() {
|
||||
<div className="my-3">
|
||||
|
||||
|
||||
<div className="mt-5">
|
||||
<span className={styles.title}>Alterar dados do vídeo</span>
|
||||
<div className="row mx-auto g-4">
|
||||
<form method="patch" onSubmit={update}>
|
||||
<input type="hidden" name="video_id" value={video?.id} />
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className="form-label fw-bold" htmlFor="title">Título</label>
|
||||
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" defaultValue={video?.title} />
|
||||
<div className="mt-5">
|
||||
<span className={styles.title}>Alterar dados do vídeo</span>
|
||||
<div className="row mx-auto g-4">
|
||||
<form method="patch" onSubmit={update}>
|
||||
<input type="hidden" name="video_id" value={video?.id} />
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="title">Título</label>
|
||||
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" defaultValue={video?.title} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="description">Descrição</label>
|
||||
<textarea rows={5} className="form-control py-2" id="description" name="description" defaultValue={video?.description} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="tags">Tags <small className="text-muted">(separar por vírgulas)</small></label>
|
||||
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" defaultValue={video?.tags} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="thumbnail">Atualizar thumbnail</label>
|
||||
<div className="position-relative">
|
||||
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1 me-2" /> {thumbnailFile ? thumbnailFile.name : "Carregar nova imagem"} </span>
|
||||
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
|
||||
<textarea rows={5} className="form-control py-2" id="description" name="description" defaultValue={video?.description} />
|
||||
</div>
|
||||
<div className="col-12 col-sm-2 px-1 text-start mb-3">
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="order">Ordem</label>
|
||||
<input type="number" className="form-control py-2 text-truncate" id="order" name="order" value={order ?? ''} onChange={(e) => setOrder(parseInt(e.target.value, 10))} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<div className="d-flex">
|
||||
<label className={`${styles.label} form-label fw-bold align-content-center mb-0 me-2`} htmlFor="category_id">Categorias</label>
|
||||
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className="form-label fw-bold" htmlFor="tags">Tags <small className="text-muted">(separar por vírgulas)</small></label>
|
||||
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" defaultValue={video?.tags} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<label className="form-label fw-bold" htmlFor="thumbnail">Atualizar thumbnail</label>
|
||||
<div className="position-relative">
|
||||
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1 me-2" /> {thumbnailFile ? thumbnailFile.name : "Carregar nova imagem"} </span>
|
||||
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start mb-3">
|
||||
<div className="d-flex">
|
||||
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
|
||||
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
|
||||
</div>
|
||||
<div>
|
||||
{categories && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<div key={category.id}>
|
||||
<input type="checkbox" name="category_ids[]" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox} />
|
||||
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
|
||||
</div>))
|
||||
) : (
|
||||
<div>
|
||||
<span>Nenhuma categoria encontrada</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{categories && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<div key={category.id}>
|
||||
<input type="checkbox" name="category_ids[]" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox} />
|
||||
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
|
||||
</div>))
|
||||
) : (
|
||||
<div>
|
||||
<span>Nenhuma categoria encontrada</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
|
||||
</div>
|
||||
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start d-flex flex-column mb-3">
|
||||
<label className="form-label fw-bold" htmlFor="tags">Estado</label>
|
||||
<div className="d-flex gap-4">
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
name="is_active"
|
||||
id="is_active_true"
|
||||
value="1"
|
||||
checked={isActive === true}
|
||||
onChange={() => setIsActive(true)}
|
||||
/>
|
||||
<label className="ms-1" htmlFor="is_active_true">Ativo</label>
|
||||
</span>
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
name="is_active"
|
||||
id="is_active_false"
|
||||
value="0"
|
||||
checked={isActive === false}
|
||||
onChange={() => setIsActive(false)}
|
||||
/>
|
||||
<label className="ms-1" htmlFor="is_active_false">Inativo</label>
|
||||
</span>
|
||||
</div>
|
||||
{/* <select className="form-control py-2 text-truncate" id="is_active" name="is_active" defaultValue={video?.is_active ? "Ativo" : "Inativo"} onChange={(e) => setIsActive(e.target.value)}>
|
||||
<option value="1">Ativo</option>
|
||||
<option value="0">Inativo</option>
|
||||
</select> */}
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start d-flex flex-column mb-3">
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="tags">Estado</label>
|
||||
<div className="d-flex gap-4">
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
name="is_active"
|
||||
id="is_active_true"
|
||||
value="1"
|
||||
checked={isActive === true}
|
||||
onChange={() => setIsActive(true)}
|
||||
/>
|
||||
<label className="ms-1" htmlFor="is_active_true">Ativo</label>
|
||||
</span>
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
name="is_active"
|
||||
id="is_active_false"
|
||||
value="0"
|
||||
checked={isActive === false}
|
||||
onChange={() => setIsActive(false)}
|
||||
/>
|
||||
<label className="ms-1" htmlFor="is_active_false">Inativo</label>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center" >
|
||||
<button type="submit" className={`${styles.submitButton} bg-primary`}>Submeter Dados <LuCheck className="mb-1 text-white" /></button>
|
||||
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center flex-wrap" >
|
||||
<button type="submit" className={`${styles.submitButton} bg-primary`}>Submeter Dados <LuCheck className="mb-1 text-white" /></button>
|
||||
<button type="button" className={`${styles.deleteButton}`} onClick={() => handleDeleteVideo()}>Apagar <LuTrash2 className="mb-1 text-white" /></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center alert alert-danger mt-5 align-items-center">
|
||||
<span>{error?.message}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
@@ -17,6 +18,12 @@
|
||||
font-size: var(--size-font-title);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--size-font-small);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statusAlert{
|
||||
width: 100px;
|
||||
border-radius: var(--border-radius-input);
|
||||
@@ -45,7 +52,7 @@
|
||||
|
||||
.btnAdicionarCategoria{
|
||||
background-color: transparent;
|
||||
color: var(--primary-color);
|
||||
color: var(--primary-contrast-color);
|
||||
border: none;
|
||||
font-size: var(--size-font-subtitle);
|
||||
padding: 0;
|
||||
@@ -65,7 +72,7 @@
|
||||
border-radius: var(--border-radius-button);
|
||||
padding: 10px 20px;
|
||||
font-size: var(--size-font-small);
|
||||
|
||||
min-width: 205px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
@@ -81,7 +88,7 @@
|
||||
border-radius: var(--border-radius-button);
|
||||
padding: 10px 20px;
|
||||
font-size: var(--size-font-small);
|
||||
|
||||
min-width: 205px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import type { ApiErrorResponse, User, Workshop } from "../../../../types";
|
||||
import { LuArrowLeft, LuCalendar, LuCheck, LuClock3, LuPencil, LuTrash2, LuUpload, LuUsers, LuX, LuClipboardList } from "react-icons/lu";
|
||||
import { LuArrowLeft, LuCalendar, LuCheck, LuClock3, LuPencil, LuTrash2, LuUpload, LuUsers, LuX} from "react-icons/lu";
|
||||
import { pt } from "date-fns/locale";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import Swal from "sweetalert2";
|
||||
import styles from "./styles.module.css";
|
||||
import { PiWarningCircleLight } from "react-icons/pi";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
|
||||
|
||||
export default function Workshop() {
|
||||
const user = JSON.parse(localStorage.getItem("user") as unknown as string) as User;
|
||||
@@ -113,7 +113,7 @@ export default function Workshop() {
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
});
|
||||
if(overrides?.status === "canceled") {
|
||||
if (overrides?.status === "canceled") {
|
||||
Swal.fire({
|
||||
title: "Workshop cancelado com sucesso",
|
||||
icon: 'error',
|
||||
@@ -205,130 +205,140 @@ export default function Workshop() {
|
||||
{workshop ? (
|
||||
<>
|
||||
<div className={`${styles.container} d-flex flex-column gap-2`}>
|
||||
<span className={styles.title}>{workshop.title}</span>
|
||||
|
||||
<div className="d-flex flex-column flex-md-row mt-4 gap-2 gap-md-4 gap-lg-5">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={`${styles.thumbnail} align-self-center align-self-sm-center`} />
|
||||
<div className="d-flex flex-column justify-content-center gap-2">
|
||||
<div className="d-flex flex-column flex-wrap gap-1 justify-content-center justify-content-md-start mt-3 mt-md-0">
|
||||
<div className="row mt-4 g-3 gx-md-4 gx-lg-5 ms-0">
|
||||
<div className={`${styles.thumbnailWrapper} col-12 col-lg-3 align-self-center align-self-sm-center px-0 mt-0`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={`${styles.thumbnail} w-100`}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-lg-9 d-flex flex-column justify-content-between gap-2">
|
||||
<div className="d-flex flex-column flex-wrap gap-1 justify-content-center justify-content-md-start">
|
||||
{isAdmin ? (
|
||||
<span className={`${styles.statusWorkshop} text-center d-inline-block py-2 mb-0 ${workshop.status === "pending" ? "alert alert-primary" : workshop.status === "realized" ? "alert alert-success" : "alert alert-danger"}`} style={{ width: "fit-content" }}>
|
||||
<span className={`${styles.statusWorkshop} text-center d-inline-block py-1 mb-1 mb-sm-0 ${workshop.status === "pending" ? "alert alert-primary" : workshop.status === "realized" ? "alert alert-success" : "alert alert-danger"}`} style={{ width: "fit-content" }}>
|
||||
<span className="fw-bold">{workshop.status === "pending" ? (<><LuClock3 className="mb-1" /> Agendado</>) : workshop.status === "realized" ? (<><LuCheck className="mb-1" /> Realizado</>) : (<><LuX className="mb-1" /> Cancelado</>)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<div className="d-flex flex-wrap text-start gap-1 mt-2">
|
||||
<span className={`${styles.dateWorkshop} text-start d-inline-block`}>
|
||||
<LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} />{workshop.date.split("-").reverse().join("-")}
|
||||
</span>
|
||||
|
||||
<span className={`${styles.timeWorkshop} text-start d-inline-block`}>
|
||||
<LuClock3 className={`${styles.iconClock} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className={`${styles.title} text-start d-inline-block`}>{workshop.title}</h2>
|
||||
<p className={`${styles.description} text-start`}>{workshop.description}</p>
|
||||
</div>
|
||||
|
||||
{isAdmin && workshop.users.length === 0 ? (
|
||||
<div className="">
|
||||
<div className="text-decoration-none text-center d-flex flex-column gap-2 align-items-center">
|
||||
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}><PiWarningCircleLight className={`${styles.iconWarning} mb-1 me-2`} />Não há utilizadores inscritos neste workshop</span>
|
||||
<div className="col-12 px-0">
|
||||
<div className="d-flex flex-wrap gap-3 text-start gap-1 mt-2">
|
||||
<div className={`${styles.dateWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
|
||||
<small className="d-block text-muted me-2"><LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} /> Dia</small>
|
||||
{workshop.date.split("-").reverse().join("-")}
|
||||
</div>
|
||||
|
||||
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
|
||||
<small className="d-block text-muted me-2"><LuClock3 className={`${styles.iconClock} mb-1 me-2`} /> Hora</small>
|
||||
{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
|
||||
</div>
|
||||
|
||||
{isAdmin && workshop.users.length === 0 ? (
|
||||
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
|
||||
<small className="d-block text-muted me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
|
||||
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}>0 utilizadores</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isAdmin && workshop.users.length > 0 ? (
|
||||
<div className="d-flex text-start flex-wrap gap-1 mt-2">
|
||||
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}><LuUsers className="mb-1 me-2" /> {workshop.users.length === 1 ? "1 utilizador inscrito" : `${workshop.users.length} utilizadores inscritos`}</span>
|
||||
<a type="button" className="align-content-center text-muted text-decoration-none fw-semibold fs-6" onClick={() => { setListagemInscritos(true); setFormEdit(false) }}>
|
||||
(<LuClipboardList className="mb-1 me-2" /> Ver utilizadores)
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
) : isAdmin && workshop.users.length > 0 ? (
|
||||
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
|
||||
<small className="d-block text-muted mb-1 me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
|
||||
<span className={`${styles.contagemUtilizadoresInscritos} fs-6 px-0`}>{workshop.users.length === 1 ? "1 utilizador" : `${workshop.users.length} utilizadores`} </span>
|
||||
<a type="button" className="align-content-center text-muted text-decoration-none fw-semibold fs-6 ms-1" onClick={() => { setListagemInscritos(true); setFormEdit(false) }}>
|
||||
<small className="text-muted"> (Ver todos)</small>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-column gap-2 justify-content-center mt-3">
|
||||
<p className={`${styles.description} text-start mb-3`}>{workshop.description}</p>
|
||||
</div>
|
||||
|
||||
{!formEdit && isAdmin ? (
|
||||
<div className={`${styles.buttonsContainer} mt-3 d-flex flex-wrap gap-2 justify-content-center`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.updateButton} bg-primary`}
|
||||
onClick={() => {
|
||||
setFormEdit(true);
|
||||
setListagemInscritos(false);
|
||||
setTitle(workshop.title);
|
||||
setDescription(workshop.description);
|
||||
setImage(null);
|
||||
setDate(new Date(workshop.date));
|
||||
setTimeStart(new Date(`1970-01-01T${workshop.time_start}`));
|
||||
setTimeEnd(new Date(`1970-01-01T${workshop.time_end}`));
|
||||
}}
|
||||
>
|
||||
<LuPencil className="mb-1" /> Editar
|
||||
</button>
|
||||
{workshop.status === "pending" ? (
|
||||
<button type="button" className={`${styles.cancelButton}`} onClick={handleCancelWorkshop}><LuTrash2 className="mb-1 btn-danger" /> Cancelar Workshop</button>
|
||||
) : null}
|
||||
{listagemInscritos ? (
|
||||
<div className={`${styles.users} mt-4`}>
|
||||
<div className="table-responsive">
|
||||
<button type="button" className={`${styles.btnClose} d-flex float-end p-1 mb-1`} onClick={() => setListagemInscritos(false)}><LuX className={`${styles.iconClose}`} /> </button>
|
||||
<table className="table table-striped table-hover align-middle mt-3">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Nome</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Inscrito em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workshop.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="text-muted fw-semibold">#{user.id}</td>
|
||||
<td>{user.name}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
{user.pivot?.created_at
|
||||
? new Date(user.pivot.created_at).toLocaleString("pt-PT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<div className={`${styles.users} mt-4`}>
|
||||
{listagemInscritos ? (
|
||||
<div className="table-responsive">
|
||||
<button type="button" className={`${styles.btnClose} d-flex float-end`} onClick={() => setListagemInscritos(false)}><LuX className={`${styles.iconClose} mb-1`} /> </button>
|
||||
<table className="table table-striped table-hover align-middle mt-3">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Nome</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Inscrito em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workshop.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="text-muted fw-semibold">#{user.id}</td>
|
||||
<td>{user.name}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
{user.pivot?.created_at
|
||||
? new Date(user.pivot.created_at).toLocaleString("pt-PT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!formEdit && isAdmin ? (
|
||||
<div className={`${styles.buttonsContainer} d-flex flex-wrap gap-2 justify-content-center mt-5`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.updateButton} bg-primary`}
|
||||
onClick={() => {
|
||||
setFormEdit(true);
|
||||
setListagemInscritos(false);
|
||||
setTitle(workshop.title);
|
||||
setDescription(workshop.description);
|
||||
setImage(null);
|
||||
setDate(new Date(workshop.date));
|
||||
setTimeStart(new Date(`1970-01-01T${workshop.time_start}`));
|
||||
setTimeEnd(new Date(`1970-01-01T${workshop.time_end}`));
|
||||
}}
|
||||
>
|
||||
<LuPencil className="mb-1" /> Editar
|
||||
</button>
|
||||
{workshop.status === "pending" ? (
|
||||
<button type="button" className={`${styles.cancelButton}`} onClick={handleCancelWorkshop}><LuTrash2 className="mb-1 btn-danger" /> Cancelar Workshop</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
|
||||
|
||||
{formEdit ? (
|
||||
<form className="mt-3" onSubmit={update}>
|
||||
<form className={`${styles.formEdit} mt-5`} onSubmit={update}>
|
||||
<span className={styles.subtitle}>Alterar detalhes do workshop</span>
|
||||
<div className="row mx-auto g-4 mt-1">
|
||||
<div className="col-12 px-1 text-start">
|
||||
<label className="form-label fw-bold" htmlFor="title">Título</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="title">Título</label>
|
||||
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" placeholder="Insira o título do workshop" defaultValue={workshop.title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start">
|
||||
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="description">Descrição</label>
|
||||
<textarea rows={5} className="form-control py-2" id="description" name="description" placeholder="Insira uma descrição para o workshop" defaultValue={workshop.description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-12 px-1 text-start">
|
||||
<label className="form-label fw-bold" htmlFor="image">Adicionar imagem</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="image">Adicionar imagem</label>
|
||||
<div className="position-relative">
|
||||
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}>
|
||||
<LuUpload className="mb-1 me-2" /> {image ? image.name : "Carregar nova imagem"}
|
||||
@@ -337,7 +347,7 @@ export default function Workshop() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
|
||||
<label className="form-label fw-bold mb-2" htmlFor="date">Data</label>
|
||||
<label className={`${styles.label} form-label fw-bold mb-2`} htmlFor="date">Data</label>
|
||||
<DatePicker
|
||||
selected={date ?? new Date(workshop.date)}
|
||||
onChange={(d: Date | null) => setDate(d)}
|
||||
@@ -351,7 +361,7 @@ export default function Workshop() {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
|
||||
<label className="form-label fw-bold" htmlFor="time_start">Hora de início</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_start">Hora de início</label>
|
||||
<DatePicker
|
||||
selected={time_start ? time_start : new Date(`1970-01-01T${workshop.time_start}`)}
|
||||
onChange={(t: Date | null) => setTimeStart(t)}
|
||||
@@ -370,7 +380,7 @@ export default function Workshop() {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
|
||||
<label className="form-label fw-bold" htmlFor="time_end">Hora de término</label>
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_end">Hora de término</label>
|
||||
<DatePicker
|
||||
selected={time_end ? time_end : new Date(`1970-01-01T${workshop.time_end}`)}
|
||||
onChange={(t: Date | null) => setTimeEnd(t)}
|
||||
@@ -394,8 +404,8 @@ export default function Workshop() {
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
|
||||
<label className="form-label fw-bold" htmlFor="tags">Estado</label>
|
||||
<div className="d-flex gap-4">
|
||||
<label className={`${styles.label} form-label fw-bold`} htmlFor="tags">Estado</label>
|
||||
<div className="d-flex flex-column flex-sm-row gap-2 gap-sm-4">
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -441,8 +451,8 @@ export default function Workshop() {
|
||||
) : null}
|
||||
|
||||
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center">
|
||||
<button type="button" className={`${styles.cancelButton}`} onClick={() => setFormEdit(false)}> Cancelar <LuX className="mb-1 text-white" /></button>
|
||||
<button type="submit" className={`${styles.updateButton} bg-primary`} >Submeter dados <LuCheck className="mb-1 text-white" /></button>
|
||||
<button type="button" className={`${styles.cancelButton}`} onClick={() => setFormEdit(false)}> <LuX className="mb-1" /> Cancelar </button>
|
||||
<button type="submit" className={`${styles.updateButton} bg-primary`} ><LuCheck className="mb-1" /> Submeter dados</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
@@ -22,21 +23,31 @@
|
||||
font-size: var(--size-font-subtitle);
|
||||
}
|
||||
|
||||
.thumbnailWrapper{
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 250px;
|
||||
border-radius: var(--border-radius);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumbnail{
|
||||
width: 300px;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.dateWorkshop, .timeWorkshop{
|
||||
background-color: var(--bg-primary-color-opacity);
|
||||
background-color: var(--bg-white);
|
||||
color: var(--text-black);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--border-radius-button);
|
||||
box-shadow: var(--box-shadow);
|
||||
padding: 5px 8px;
|
||||
width: 180px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.contagemUtilizadoresInscritos{
|
||||
@@ -67,6 +78,19 @@
|
||||
color: var(--neutral-color);
|
||||
}
|
||||
|
||||
.formEdit{
|
||||
background-color: var(--bg-white);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--size-font-small);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.updateButton{
|
||||
background-color: var(--text-primary);
|
||||
color: var(--text-white);
|
||||
@@ -74,7 +98,7 @@
|
||||
border-radius: var(--border-radius-button);
|
||||
padding: 10px 20px;
|
||||
font-size: var(--size-font-small);
|
||||
|
||||
min-width: 205px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
@@ -104,19 +128,19 @@
|
||||
}
|
||||
|
||||
.deleteButton, .cancelButton{
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-white);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-color);
|
||||
border: 1px solid var(--text-primary-color);
|
||||
border-radius: var(--border-radius-button);
|
||||
padding: 10px 20px;
|
||||
font-size: var(--size-font-small);
|
||||
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
&:hover{
|
||||
background-color: var(--bg-primary-color-opacity) !important;
|
||||
border-color: var(--bg-primary-color-opacity) !important;
|
||||
color: var(--text-primary-color) !important;
|
||||
.icon{
|
||||
color: var(--text-primary-color) !important;
|
||||
@@ -176,6 +200,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
|
||||
@@ -5,11 +5,15 @@ import { LuArrowUpRight, LuCalendar, LuClock3, LuPencil } from "react-icons/lu";
|
||||
import { Link } from "react-router";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import { ProgressBar } from "react-bootstrap";
|
||||
import { usePreloadImages } from "../../../hooks/usePreloadImages";
|
||||
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser"
|
||||
import { useGetWorkshops } from "../../../hooks/useGetWorkshops";
|
||||
import { useGetVideos } from "../../../hooks/useGetVideos";
|
||||
|
||||
import { useGetVideosLength } from "../../../hooks/useGetVideosLength";
|
||||
import { useNextVideos } from "../../../hooks/useNextVideos";
|
||||
import { useNextWorkshops } from "../../../hooks/useNextWorkshops";
|
||||
import { useGetWorkshopsLength } from "../../../hooks/useGetWorkshopsLength";
|
||||
import Swal from "sweetalert2";
|
||||
import { PiCheckCircleFill } from "react-icons/pi";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
|
||||
|
||||
export default function Home() {
|
||||
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
|
||||
@@ -18,58 +22,65 @@ export default function Home() {
|
||||
const [error, setError] = useState<ApiErrorResponse | null>(null);
|
||||
const [messageSuccess, setMessageSuccess] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const { preloadImages } = usePreloadImages();
|
||||
const { getCurrentUser } = useGetCurrentUser();
|
||||
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
|
||||
const { getWorkshops } = useGetWorkshops();
|
||||
const [workshops, setWorkshops] = useState<Workshop[]>([]);
|
||||
const { getVideos } = useGetVideos();
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const { getVideosLength } = useGetVideosLength();
|
||||
const { getNextVideos } = useNextVideos();
|
||||
const [nextVideos, setNextVideos] = useState<Video[]>([]);
|
||||
const { getNextWorkshops } = useNextWorkshops();
|
||||
const [nextWorkshops, setNextWorkshops] = useState<Workshop[]>([]);
|
||||
const { getWorkshopsLength } = useGetWorkshopsLength();
|
||||
const [videosStats, setVideosStats] = useState({
|
||||
videos: 0,
|
||||
videosWatched: 0
|
||||
});
|
||||
const [workshopsStats, setWorkshopsStats] = useState({
|
||||
workshops: 0,
|
||||
workshopsInscribed: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const [workshopsData, videosData, currentUserData] = await Promise.all([
|
||||
getWorkshops(),
|
||||
getVideos(),
|
||||
getCurrentUser(),
|
||||
]);
|
||||
try {
|
||||
const [
|
||||
currentUserData,
|
||||
videosLengthData,
|
||||
workshopsLengthData,
|
||||
nextVideosData,
|
||||
nextWorkshopsData
|
||||
] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
getVideosLength(),
|
||||
getWorkshopsLength(),
|
||||
getNextVideos(),
|
||||
getNextWorkshops(),
|
||||
]);
|
||||
|
||||
setWorkshops(workshopsData as Workshop[]);
|
||||
setVideos(videosData as Video[]);
|
||||
setCurrentUserData(currentUserData.data);
|
||||
setCurrentUserData((currentUserData as { data: User }).data);
|
||||
setVideosStats(videosLengthData as { videos: number, videosWatched: number });
|
||||
setWorkshopsStats(workshopsLengthData as { workshops: number, workshopsInscribed: number });
|
||||
|
||||
await preloadImages([
|
||||
...(workshopsData as Workshop[]).map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
|
||||
...(videosData as Video[]).map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
|
||||
]);
|
||||
if ("videos" in nextVideosData) {
|
||||
setNextVideos(nextVideosData.videos);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
if ("workshops" in nextWorkshopsData) {
|
||||
setNextWorkshops(nextWorkshopsData.workshops as unknown as Workshop[]);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
}, []);
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const nextWorkshops = [...workshops] // ... cria uma cópia do array para não alterar o original
|
||||
.filter((w: Workshop) =>
|
||||
new Date(w.date + "T" + w.time_start) >= today &&
|
||||
w.status === "pending"
|
||||
) // Percorre cada workshop e só mantém os que têm data maior ou igual a hoje e status "pending"
|
||||
.sort((a: Workshop, b: Workshop) => {
|
||||
return (new Date(a.date + "T" + a.time_start).getTime() -
|
||||
new Date(b.date + "T" + b.time_start).getTime()
|
||||
);
|
||||
})
|
||||
.slice(0, 3); // pega nos próximos 3
|
||||
|
||||
/* Vídeos */
|
||||
|
||||
const now = 0;
|
||||
|
||||
const nextVideos = videos.slice(0, 3);
|
||||
let progressoVideos = Math.round(videosStats.videosWatched / videosStats.videos * 100);
|
||||
|
||||
/* Inscrever num workshop */
|
||||
async function inscrever(workshopId: number) {
|
||||
@@ -92,8 +103,10 @@ export default function Home() {
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
});
|
||||
const workshops = await getWorkshops();
|
||||
setWorkshops(workshops as Workshop[]);
|
||||
const result = await getNextWorkshops();
|
||||
if ("workshops" in result) {
|
||||
setNextWorkshops(result.workshops as unknown as Workshop[]);
|
||||
}
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: data.message,
|
||||
@@ -124,8 +137,10 @@ export default function Home() {
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
});
|
||||
const workshops = await getWorkshops();
|
||||
setWorkshops(workshops as Workshop[]);
|
||||
const result = await getNextWorkshops();
|
||||
if ("workshops" in result) {
|
||||
setNextWorkshops(result.workshops as unknown as Workshop[]);
|
||||
}
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: data.message,
|
||||
@@ -136,8 +151,6 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === currentUserData?.id)));
|
||||
|
||||
/* Formulário de contacto */
|
||||
async function sendMail(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -193,50 +206,74 @@ export default function Home() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
|
||||
<div className={`${styles.container} mx-2 mx-sm-0 p-2 p-sm-4 p-lg-0`}>
|
||||
|
||||
<div className=" ps-0">
|
||||
<div className={`${styles.containerVideos} px-2 p-sm-4`}>
|
||||
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center px-0" >
|
||||
<h2 className={`${styles.subtitle} text-center text-sm-start mt-4 mt-sm-3 mb-4 px-0 `}>Continuar Formação</h2>
|
||||
<h2 className={`${styles.subtitle} text-center text-sm-start mt-4 mt-sm-3 mb-4 px-0 `}>{isAdmin ? "Vídeos ativos" : "Continuar Formação"}</h2>
|
||||
<Link to="/videos" className={`${styles.link} text-decoration-none fw-semibold fs-5 mt-sm-3 mb-4`}>Ver vídeos <LuArrowUpRight className="mb-1 me-2" size={25} /></Link>
|
||||
</div>
|
||||
|
||||
<ProgressBar className={`${styles.progressBar} px-1`} now={now} label={`${now}%`} />
|
||||
{!isAdmin && (
|
||||
<>
|
||||
<ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} />
|
||||
|
||||
<div className="row mt-4">
|
||||
{nextVideos.length > 0 ? nextVideos.map((video) => (
|
||||
{progressoVideos === 100 && (
|
||||
<div className="text-center mt-3">
|
||||
<span className="text-black fw-bold fs-6">Parabéns! A sua formação está completa!</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="row mt-4 px-2">
|
||||
{nextVideos.length > 0 ? nextVideos.map((video: Video) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2">
|
||||
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
|
||||
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
|
||||
{isAdmin && (
|
||||
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
|
||||
)}
|
||||
<img className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
|
||||
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
|
||||
alt={video.title}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
|
||||
<h2 className={`${styles.titleVideo} d-flex text-wrap mb-1`}>{video.title}</h2>
|
||||
{/* <span className={`${styles.descriptionVideo} mt-0 pe-3 text-truncate`}>{video.description}</span> */}
|
||||
</div>
|
||||
{video.watched && <span className="d-block badge text-success text-start fs-6 position-absolute top-0 start-0 bg-success-subtle text-success px-3 py-2" style={{ borderRadius: "0 0 var(--border-radius) 0" }}><PiCheckCircleFill className="mb-1 me-1" /> Visto</span>}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
)) : <div className="col-12 text-start mt-4 px-0">
|
||||
<span className={` text-muted fs-5`}>Nenhum vídeo encontrado</span>
|
||||
</div>}
|
||||
)) : nextVideos.length === 0 ? (
|
||||
<div className="col-12 text-start ps-1">
|
||||
<span className="text-muted fs-5">Nenhum vídeo encontrado</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ms-0 ps-0 mt-4">
|
||||
<div className="ms-0 px-4 mt-4">
|
||||
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center px-0" >
|
||||
<h2 className={`${styles.subtitle} text-center text-sm-start mt-4 mt-sm-3 mb-4`}>Próximos workshops</h2>
|
||||
<Link to="/workshops" className={`${styles.link} text-decoration-none fw-semibold fs-5 mt-sm-3 mb-sm-4`}>Ver workshops <LuArrowUpRight className="mb-1 me-2" size={25} /></Link>
|
||||
</div>
|
||||
<div className="row mt-4 mt-sm-1">
|
||||
{nextWorkshops.length > 0 ? nextWorkshops.filter((workshop: Workshop) => workshop.status === "pending").slice(0, 3).map((workshop) => (
|
||||
<div className="row mt-4 mt-sm-1 px-2">
|
||||
{nextWorkshops.length > 0 ? nextWorkshops.map((workshop: Workshop) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
|
||||
<div className={`${styles.boxWorkshop} text-start pb-3`}>
|
||||
<div className="position-relative">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
|
||||
<div className={`${styles.thumbnailWorkshop} position-relative`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={styles.thumbnailWorkshop}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
||||
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
||||
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
||||
@@ -244,7 +281,6 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
|
||||
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
|
||||
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
|
||||
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
|
||||
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
|
||||
@@ -254,12 +290,12 @@ export default function Home() {
|
||||
<Link to={`${isAdmin ? `/admin/edit-workshop/${workshop.id}` : `/workshop/${workshop.id}`}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}> Detalhes </Link>
|
||||
|
||||
{!isAdmin ? (
|
||||
currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
|
||||
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
currentUserData && (workshop.users as unknown as number[]).includes(currentUserData.id) ? (
|
||||
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => cancelarInscricao(workshop.id)} key={workshop.id} style={{ width: '180px' }}>
|
||||
Anular inscrição
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => inscrever(workshop.id)} key={workshop.id} style={{ width: '180px' }}>
|
||||
Inscrever-me
|
||||
</button>
|
||||
)
|
||||
@@ -328,39 +364,21 @@ export default function Home() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="col-12 col-lg-4 mt-lg-5 px-0 ps-lg-4">
|
||||
<div className="h-100 pt-4">
|
||||
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-normal fs-3 text-white" >Vídeos assistidos</span>
|
||||
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>0/{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
|
||||
</div>
|
||||
<div className="col-12 col-lg-4 mt-lg-5 px-0 ps-lg-4">
|
||||
<div className="h-100 pt-4">
|
||||
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-normal fs-3 text-white" > {isAdmin ? "Vídeos ativos" : "Vídeos assistidos"}</span>
|
||||
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{isAdmin ? videosStats.videos : `${videosStats.videosWatched}/${videosStats.videos}`}</span>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>Workshops Inscrito</span>
|
||||
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === currentUserData?.id)).length}</span>
|
||||
</div>
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>{isAdmin ? "Workshops agendados" : "Workshops inscrito"}</span>
|
||||
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{isAdmin ? workshopsStats.workshops : `${workshopsStats.workshopsInscribed}/${workshopsStats.workshops}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isAdmin ? (
|
||||
<div className="col-12 col-lg-4 mt-lg-5 px-0 ps-lg-4">
|
||||
<div className="h-100 pt-4">
|
||||
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-normal fs-3 text-white" >Vídeos ativos</span>
|
||||
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>Workshops Agendados</span>
|
||||
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{workshops.filter((workshop: Workshop) => workshop.status === "pending").length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,17 @@
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-color);
|
||||
transition: all .3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.containerVideos {
|
||||
background-color: var(--bg-white);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.dateWorkshop,
|
||||
.timeWorkshop {
|
||||
background-color: var(--bg-white);
|
||||
@@ -65,49 +71,52 @@
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.linkWorkshop{
|
||||
.linkWorkshop {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
|
||||
|
||||
background-color: var(--bg-grey);
|
||||
color: var(--text-black);
|
||||
transition: all .3s ease;
|
||||
&:hover{
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-primary-color-opacity);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btncancelarInscricao{
|
||||
.btncancelarInscricao {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
|
||||
|
||||
background-color: var(--bg-primary-color-opacity);
|
||||
color: var(--text-primary-color);
|
||||
transition: all .3s ease;
|
||||
&:hover{
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-primary-color);
|
||||
color: var(--text-white);
|
||||
}
|
||||
}
|
||||
|
||||
.btnInscrever{
|
||||
.btnInscrever {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
|
||||
|
||||
background-color: var(--tertiary-color);
|
||||
color: var(--text-white);
|
||||
transition: all .3s ease;
|
||||
&:hover{
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-tertiary-color-opacity);
|
||||
color: var(--text-tertiary-color);
|
||||
}
|
||||
@@ -122,10 +131,10 @@
|
||||
overflow: visible;
|
||||
color: var(--text-primary-color);
|
||||
font-weight: 800;
|
||||
background-color: var(--bg-primary-color);
|
||||
background-color: var(--bg-primary-color-opacity);
|
||||
}
|
||||
|
||||
.iconEdit{
|
||||
.iconEdit {
|
||||
color: var(--text-white);
|
||||
background-color: var(--bg-primary-color);
|
||||
font-size: 2.3rem;
|
||||
@@ -138,7 +147,8 @@
|
||||
border-radius: var(--border-radius);
|
||||
padding: 10px;
|
||||
transition: all 0.3s ease;
|
||||
&:hover{
|
||||
|
||||
&:hover {
|
||||
background-color: var(--neutral-color);
|
||||
}
|
||||
}
|
||||
@@ -152,14 +162,32 @@
|
||||
}
|
||||
|
||||
.boxVideo {
|
||||
background: #e0e0e0; /* cinzento enquanto a imagem não carrega */
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.thumbnailWorkshop {
|
||||
background: #e0e0e0; /* cinzento enquanto a imagem não carrega */
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.boxVideo::after {
|
||||
content: '';
|
||||
content: '\F4F4';
|
||||
color: var(--bg-grey);
|
||||
font-size: 4rem;
|
||||
align-content: center;
|
||||
font-family: 'bootstrap-icons';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -167,9 +195,14 @@
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
|
||||
z-index: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.boxVideoInfo{
|
||||
.boxVideo:hover::after {
|
||||
color: var(--bg-primary-color);
|
||||
}
|
||||
|
||||
.boxVideoInfo {
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
@@ -196,38 +229,39 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.formContact{
|
||||
.formContact {
|
||||
background-color: var(--bg-white);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.label{
|
||||
.label {
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--size-font-small);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submitButton{
|
||||
.submitButton {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-white);
|
||||
border-radius: var(--border-radius-button);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: var(--size-font-small);
|
||||
|
||||
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
&:hover{
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-primary-color-opacity);
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.userVideosWatched{
|
||||
.userVideosWatched {
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-primary-color);
|
||||
background-color: var(--bg-neutral-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +269,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
.userVideosWatched{
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-primary-color);
|
||||
background-color: var(--bg-neutral-color);
|
||||
}
|
||||
|
||||
.closeFormButton{
|
||||
|
||||
@@ -4,14 +4,18 @@ import { Link, useSearchParams } from "react-router";
|
||||
import styles from "./styles.module.css";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import { LuCalendar, LuClock3, LuPencil } from "react-icons/lu";
|
||||
import { usePreloadImages } from "../../../hooks/usePreloadImages";
|
||||
import { useGetVideosSearch } from "../../../hooks/useGetVideosSearch";
|
||||
import { useGetWorkshopsSearch } from "../../../hooks/useGetWorkshopsSearch";
|
||||
import { PiCheckCircleFill } from "react-icons/pi";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
|
||||
|
||||
export default function Search() {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [workshops, setWorkshops] = useState<Workshop[]>([]);
|
||||
const [error, setError] = useState<ApiErrorResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { preloadImages } = usePreloadImages();
|
||||
const { getVideosSearch } = useGetVideosSearch();
|
||||
const { getWorkshopsSearch } = useGetWorkshopsSearch();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const query = (searchParams.get("q") ?? "").trim();
|
||||
@@ -22,73 +26,36 @@ export default function Search() {
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
setLoading(true);
|
||||
const [videoData, workshopData] = await getSearch();
|
||||
|
||||
|
||||
await preloadImages([
|
||||
...videoData.map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
|
||||
...workshopData.map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
|
||||
]);
|
||||
|
||||
|
||||
const [videoResponse, workshopResponse] = await Promise.all([
|
||||
getVideosSearch(query),
|
||||
getWorkshopsSearch(query),
|
||||
]);
|
||||
|
||||
let videoData: Video[] = [];
|
||||
let workshopData: Workshop[] = [];
|
||||
|
||||
if("videos" in videoResponse) {
|
||||
videoData = videoResponse.videos;
|
||||
setVideos(videoData);
|
||||
} else {
|
||||
setVideos([]);
|
||||
}
|
||||
|
||||
if("workshops" in workshopResponse) {
|
||||
workshopData = workshopResponse.workshops;
|
||||
setWorkshops(workshopData);
|
||||
} else {
|
||||
setWorkshops([]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
}, [query]);
|
||||
|
||||
async function getSearch(): Promise<[Video[], Workshop[]]> {
|
||||
if (query === "") {
|
||||
setVideos([]);
|
||||
setWorkshops([]);
|
||||
setLoading(false);
|
||||
setError({ message: "A pesquisa não pode ser vazia",
|
||||
data: null,
|
||||
errors: {} });
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
const [videoResponse, workshopResponse] = await Promise.all([
|
||||
fetch(`http://127.0.0.1:8000/api/videos?search=${encodeURIComponent(query)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
}),
|
||||
|
||||
fetch(`http://127.0.0.1:8000/api/workshops?search=${encodeURIComponent(query)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
let videoData: Video[] = [];
|
||||
let workshopData: Workshop[] = [];
|
||||
|
||||
if(videoResponse.ok) {
|
||||
const data = await videoResponse.json();
|
||||
videoData = data.data as Video[];
|
||||
setVideos(videoData);
|
||||
} else {
|
||||
setVideos([]);
|
||||
}
|
||||
|
||||
if(workshopResponse.ok) {
|
||||
const data = await workshopResponse.json();
|
||||
workshopData = data.data as Workshop[];
|
||||
setWorkshops(workshopData);
|
||||
} else {
|
||||
setWorkshops([]);
|
||||
}
|
||||
|
||||
return [videoData, workshopData];
|
||||
}
|
||||
|
||||
if(loading) {
|
||||
return(
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
@@ -98,6 +65,14 @@ export default function Search() {
|
||||
);
|
||||
}
|
||||
|
||||
if(error) {
|
||||
return(
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
<span className="text-muted">Ocorreu um erro ao carregar os resultados da sua pesquisa.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={`${styles.subtitle} mb-4`}>Resultados da pesquisa: "{query}"</h1>
|
||||
@@ -107,17 +82,23 @@ export default function Search() {
|
||||
<h2 className={`${styles.title} text-center text-md-start`}>Vídeos</h2>
|
||||
<span className="text-muted text-start mt-0">{videos.length === 1 ? `Foi encontrado ${videos.length} vídeo na sua pesquisa.` : `Foram encontrados ${videos.length} vídeos na sua pesquisa.`}</span>
|
||||
{videos.map((video) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2">
|
||||
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
|
||||
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
|
||||
{isAdmin && (
|
||||
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
|
||||
)}
|
||||
<img className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
|
||||
<img
|
||||
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
|
||||
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
|
||||
alt={video.title}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
|
||||
<h2 className={`${styles.titleVideo} mb-1`}>{video.title}</h2>
|
||||
<span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span>
|
||||
<h2 className={`${styles.titleVideo} d-flex text-wrap mb-1`}>{video.title}</h2>
|
||||
</div>
|
||||
{video.watched && <span className="d-block badge text-success text-start fs-6 position-absolute top-0 start-0 bg-success-subtle text-success px-3 py-2" style={{ borderRadius: "0 0 var(--border-radius) 0" }}><PiCheckCircleFill className="mb-1 me-1" /> Visto</span>}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -130,10 +111,16 @@ export default function Search() {
|
||||
<h2 className={`${styles.title} text-center text-md-start`}>Workshops</h2>
|
||||
<span className="text-muted text-start mt-0">{workshops.length === 1 ? `Foi encontrado ${workshops.length} workshop na sua pesquisa.` : `Foram encontrados ${workshops.length} workshops na sua pesquisa.`}</span>
|
||||
{workshops.map((workshop) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2 position-relative" key={workshop.id}>
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative" key={workshop.id}>
|
||||
<div className={`${styles.boxWorkshop} text-start pb-3`}>
|
||||
<div className="position-relative">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
|
||||
<div className={`${styles.thumbnailWorkshop} position-relative`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={styles.thumbnailWorkshop}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
||||
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
||||
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
||||
@@ -141,7 +128,6 @@ export default function Search() {
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
|
||||
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
|
||||
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
|
||||
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
|
||||
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* No teu CSS */
|
||||
@import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
.container{
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
@@ -42,6 +45,9 @@
|
||||
}
|
||||
|
||||
.boxVideo {
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -49,7 +55,11 @@
|
||||
}
|
||||
|
||||
.boxVideo::after {
|
||||
content: '';
|
||||
content: '\F4F4';
|
||||
color: var(--bg-grey);
|
||||
font-size: 4rem;
|
||||
align-content: center;
|
||||
font-family: 'bootstrap-icons';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -57,6 +67,11 @@
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
|
||||
z-index: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.boxVideo:hover::after {
|
||||
color: var(--bg-primary-color);
|
||||
}
|
||||
|
||||
.boxVideoInfo{
|
||||
@@ -131,12 +146,17 @@
|
||||
}
|
||||
|
||||
.thumbnailWorkshop{
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon{
|
||||
@@ -147,6 +167,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -5,6 +5,7 @@ import styles from "./styles.module.css";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { LuPencil } from "react-icons/lu";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
|
||||
|
||||
export default function Videos() {
|
||||
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
||||
@@ -139,7 +140,13 @@ export default function Videos() {
|
||||
<div className={`${styles.boxVideo}`} data-category={video.categories?.map((category) => category.id).join(', ')}>
|
||||
<Link to={`/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
|
||||
<a className={`${styles.linkVideo} text-decoration-none pb-2`} href={`/video/${video.id}`} key={video.id} data-category={video.categories?.map((category) => category.name).join(', ')}>
|
||||
<img className={`${styles.thumbnail}`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
|
||||
<img
|
||||
className={`${styles.thumbnail}`}
|
||||
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
|
||||
alt={video.title}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className="px-3 text-start text-truncate">
|
||||
<h2 className={`${styles.titleVideo} mt-2`}>{video.title}</h2>
|
||||
<span className="text-muted text-black mt-0
|
||||
|
||||
@@ -52,7 +52,13 @@
|
||||
}
|
||||
|
||||
.boxVideo{
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.titleVideo{
|
||||
@@ -74,6 +80,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { data, Link } from "react-router";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import styles from "./styles.module.css";
|
||||
import { LuPlus, LuCalendar, LuClock3 } from "react-icons/lu";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
|
||||
|
||||
export default function Workshops() {
|
||||
const [workshops, setWorkshops] = useState<Workshop[]>([]);
|
||||
@@ -87,8 +88,14 @@ export default function Workshops() {
|
||||
{filteredWorkshops.map((workshop) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
|
||||
<div className={`${styles.boxWorkshop} text-start pb-3`}>
|
||||
<div className="position-relative">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
|
||||
<div className={`${styles.thumbnailWorkshop} position-relative`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={styles.thumbnailWorkshop}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
||||
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
||||
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
||||
|
||||
@@ -87,12 +87,17 @@
|
||||
}
|
||||
|
||||
.thumbnailWorkshop{
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon{
|
||||
@@ -103,6 +108,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import type { ApiErrorResponse, Video } from "../../../types";
|
||||
import styles from "./styles.module.css";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import { LuArrowLeft } from "react-icons/lu";
|
||||
import { Plyr } from "plyr-react";
|
||||
import "plyr-react/plyr.css";
|
||||
import { useVideoWatch } from "../../../hooks/useVideoWatch";
|
||||
import { PiCheckCircleFill } from "react-icons/pi";
|
||||
|
||||
export default function Video() {
|
||||
const { id } = useParams();
|
||||
@@ -13,25 +14,72 @@ export default function Video() {
|
||||
const [error, setError] = useState<ApiErrorResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [playerReady, setPlayerReady] = useState(false);
|
||||
const { watched, markAsWatched } = useVideoWatch(Number(id), video?.watched ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
getVideo();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
if (!video) {
|
||||
setPlayerReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setPlayerReady(false);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const videoElement = document.querySelector("video");
|
||||
|
||||
if (!videoElement) return;
|
||||
if (videoElement.src.includes("blank.mp4")) return;
|
||||
|
||||
clearInterval(interval);
|
||||
setPlayerReady(true);
|
||||
}, 1000); // espera 1 segundo após o video carregar
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => clearInterval(interval);
|
||||
}, [video]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerReady) return;
|
||||
|
||||
let videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
const handleEnded = () => {
|
||||
markAsWatched();
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const el = document.querySelector("video");
|
||||
|
||||
if (!el) return;
|
||||
if (el.src.includes("blank.mp4")) return;
|
||||
|
||||
clearInterval(interval);
|
||||
videoElement = el;
|
||||
videoElement.addEventListener("ended", handleEnded);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (videoElement) {
|
||||
videoElement.removeEventListener("ended", handleEnded);
|
||||
}
|
||||
};
|
||||
}, [playerReady, markAsWatched]);
|
||||
|
||||
useEffect(() => {
|
||||
if (video?.watched) {
|
||||
markAsWatched();
|
||||
}
|
||||
}, [video, markAsWatched]);
|
||||
|
||||
async function getVideo() {
|
||||
setLoading(true);
|
||||
setPlayerReady(false);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/edit-video/${id}`, {
|
||||
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
@@ -44,43 +92,62 @@ export default function Video() {
|
||||
|
||||
if (response.ok) {
|
||||
setVideo(data.data);
|
||||
setError(null);
|
||||
} else {
|
||||
setVideo(null);
|
||||
setError(data as ApiErrorResponse);
|
||||
}
|
||||
} catch {
|
||||
console.error(error);
|
||||
setVideo(null);
|
||||
setError({ message: "Erro de ligação" } as ApiErrorResponse);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if(loading) {
|
||||
return (
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
|
||||
<span>A carregar vídeo...</span>
|
||||
const playerSkeleton = (
|
||||
<div className={`${styles.playerContent} ${styles.videoPlayerSkeleton}`} aria-hidden="true" />
|
||||
);
|
||||
|
||||
const pageSkeleton = (
|
||||
<div className="my-3 d-flex flex-column gap-2">
|
||||
<div className={`${styles.titleSkeleton} mb-3`} aria-hidden="true" style={{ maxWidth: "1300px", margin: "0 auto" }} />
|
||||
<div style={{ maxWidth: "1300px", margin: "0 auto", width: "100%" }}>
|
||||
<div className={styles.playerWrapper}>
|
||||
{playerSkeleton}
|
||||
</div>
|
||||
<div className={styles.descriptionSkeleton} aria-hidden="true" />
|
||||
<div className={styles.descriptionSkeletonShort} aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!playerReady ? (
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
|
||||
<span>A carregar vídeo...</span>
|
||||
<div className={`${styles.container} p-3 p-xl-0`}>
|
||||
<div className="text-start">
|
||||
<button className={`${styles.button} border-0 bg-transparent fs-5`}>
|
||||
<Link className={`${styles.link} text-decoration-none`} to="/videos">
|
||||
<LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} />
|
||||
<span className={styles.linkText}>Vídeos</span>
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.container} p-3 p-xl-0`}>
|
||||
<div className={"text-start"}>
|
||||
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/videos"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Vídeos</span> </Link></button>
|
||||
</div>
|
||||
{video ? (
|
||||
<div className="my-3 d-flex flex-column gap-2">
|
||||
<span className={`${styles.title}`}>{video.title}</span>
|
||||
|
||||
<div style={{ maxWidth: "1000px", margin: "0 auto", display: playerReady ? 'block' : 'none' }}>
|
||||
{loading ? (
|
||||
pageSkeleton
|
||||
) : video ? (
|
||||
<div className="my-3 d-flex flex-column gap-2">
|
||||
<span className={`${styles.title} mb-3`}>{video.title}</span>
|
||||
|
||||
<div style={{ maxWidth: "1300px", margin: "0 auto", width: "100%" }}>
|
||||
{watched && (
|
||||
<span className="d-block badge text-success text-start fs-6 px-0">
|
||||
<PiCheckCircleFill className="mb-1 me-1" /> Visto
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.playerWrapper}>
|
||||
{!playerReady && playerSkeleton}
|
||||
<div className={`${styles.playerContent} ${playerReady ? "" : styles.playerHidden}`}>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "video",
|
||||
@@ -88,19 +155,15 @@ export default function Video() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small className="text-start"><b>Publicado a: </b>{video.created_at}</small>
|
||||
<p className="text-start mt-2">{video.description}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
|
||||
<span className="alert alert-danger">{error?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-start mt-3 fs-6">{video.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
<span className="alert alert-danger">{error?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,140 @@
|
||||
.container{
|
||||
.container {
|
||||
align-self: start;
|
||||
max-width: 1400px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.title{
|
||||
.title {
|
||||
color: var(--primary-contrast-color);
|
||||
font-size: var(--size-font-title);
|
||||
}
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
.LinkIcon,
|
||||
.linkText {
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
.button:hover .LinkIcon,
|
||||
.button:hover .linkText {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.animateSpin{
|
||||
.animateSpin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to{
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 2rem;
|
||||
width: min(100%, 480px);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.descriptionSkeleton {
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 1rem;
|
||||
width: 100%;
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.descriptionSkeletonShort {
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 1rem;
|
||||
width: 70%;
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Altura fixa 16:9 desde o primeiro paint — evita salto quando o Plyr monta */
|
||||
.playerWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-top: 56.25%;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playerContent {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.videoPlayerSkeleton {
|
||||
z-index: 2;
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.videoPlayerSkeleton::after {
|
||||
content: '\F4F4';
|
||||
color: var(--bg-grey);
|
||||
font-size: 4rem;
|
||||
align-content: center;
|
||||
font-family: 'bootstrap-icons';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playerHidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playerWrapper :global(.plyr),
|
||||
.playerWrapper :global(.plyr__video-wrapper),
|
||||
.playerWrapper :global(video) {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.playerWrapper :global(.plyr) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playerWrapper :global(.plyr__video-wrapper),
|
||||
.playerWrapper :global(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import styles from "./styles.module.css";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import { LuPlus, LuSettings2 } from "react-icons/lu";
|
||||
import { LuPencil } from "react-icons/lu";
|
||||
import { Dropdown, Form } from "react-bootstrap";
|
||||
import { usePreloadImages } from "../../../hooks/usePreloadImages";
|
||||
import { Dropdown, Form, Pagination } from "react-bootstrap";
|
||||
import { useGetVideos } from "../../../hooks/useGetVideos";
|
||||
import { useDebounce } from "../../../hooks/useDebounce";
|
||||
import { PiCheckCircleFill } from "react-icons/pi";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
|
||||
|
||||
export default function Videos() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -16,12 +17,19 @@ export default function Videos() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const { preloadImages } = usePreloadImages();
|
||||
const { getVideos } = useGetVideos();
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [searchCompleted, setSearchCompleted] = useState(false);
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [loadingVideos, setLoadingVideos] = useState(false);
|
||||
const videosToShow = videos;
|
||||
|
||||
useEffect(() => {
|
||||
getRole();
|
||||
getCategories();
|
||||
}, []);
|
||||
|
||||
async function getRole() {
|
||||
const response = await fetch("http://127.0.0.1:8000/api/me", {
|
||||
@@ -43,7 +51,7 @@ export default function Videos() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCategories() {
|
||||
async function getCategories() {
|
||||
const response = await fetch("http://127.0.0.1:8000/api/categories", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -52,68 +60,63 @@ export default function Videos() {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCategories(data.data as Category[]);
|
||||
setError(null);
|
||||
return data.data as Category[];
|
||||
setCategories(data.data);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setCategories([]);
|
||||
setError(data as ApiErrorResponse);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSearchCompleted(false);
|
||||
|
||||
const fetchAll = async () => {
|
||||
getRole();
|
||||
|
||||
const fetchVideos = async () => {
|
||||
setLoadingVideos(true);
|
||||
|
||||
try {
|
||||
const [videosData] = await Promise.all([
|
||||
getVideos(debouncedSearch),
|
||||
getCategories(),
|
||||
]);
|
||||
|
||||
if (Array.isArray(videosData)) {
|
||||
setVideos(videosData);
|
||||
const videosData = await getVideos({
|
||||
page: currentPage,
|
||||
search: debouncedSearch,
|
||||
category:
|
||||
selectedCategoryId !== "all" &&
|
||||
selectedCategoryId !== "active" &&
|
||||
selectedCategoryId !== "inactive" &&
|
||||
selectedCategoryId !== "watched" &&
|
||||
selectedCategoryId !== "unwatched"
|
||||
? selectedCategoryId
|
||||
: undefined,
|
||||
status:
|
||||
selectedCategoryId === "active"
|
||||
? "active"
|
||||
: selectedCategoryId === "inactive"
|
||||
? "inactive"
|
||||
: undefined,
|
||||
watched:
|
||||
selectedCategoryId === "watched"
|
||||
? 1
|
||||
: selectedCategoryId === "unwatched"
|
||||
? 0
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if ("videos" in videosData) {
|
||||
setVideos(videosData.videos);
|
||||
setLastPage(videosData.meta.last_page);
|
||||
setCurrentPage(videosData.meta.current_page);
|
||||
} else {
|
||||
setVideos([]);
|
||||
}
|
||||
|
||||
await preloadImages([
|
||||
...(videosData as Video[]).map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
|
||||
]);
|
||||
|
||||
setVideos(videosData as Video[]);
|
||||
setSearchCompleted(true);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
|
||||
} catch {
|
||||
setVideos([]);
|
||||
setError(e as ApiErrorResponse);
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSearchCompleted(true);
|
||||
setLoadingVideos(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const filteredVideos = videos.filter((video) => {
|
||||
if (selectedCategoryId === "all") return true;
|
||||
if (selectedCategoryId === "active") return Boolean(video.is_active);
|
||||
if (selectedCategoryId === "inactive") return !video.is_active;
|
||||
|
||||
return (video.categories ?? []).some(
|
||||
(category) => String(category.id) === selectedCategoryId
|
||||
);
|
||||
});
|
||||
|
||||
fetchVideos();
|
||||
}, [debouncedSearch, currentPage, selectedCategoryId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -125,16 +128,16 @@ export default function Videos() {
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
|
||||
<h1 className={`${styles.title} mt-1`}>Videos</h1>
|
||||
<h1 className={`${styles.title} mt-1`}>Vídeos</h1>
|
||||
|
||||
{error && (
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
<span className="text-muted fs-5">{error.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="row py-3 g-4 justify-content-between">
|
||||
<div className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2 ">
|
||||
<div className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2">
|
||||
<Form.Control type="text" placeholder="Pesquisar vídeos..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -143,15 +146,27 @@ export default function Videos() {
|
||||
|
||||
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
|
||||
<Dropdown className="flex-grow-1" onSelect={(value) => {
|
||||
setCurrentPage(1);
|
||||
if (value) {
|
||||
setSelectedCategoryId(value);
|
||||
}
|
||||
}}>
|
||||
<Dropdown.Toggle variant="outline-secondary" className="w-100">
|
||||
<LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' : selectedCategoryId === 'active' ? 'Ativos' : 'Inativos'}
|
||||
<LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' :
|
||||
selectedCategoryId === 'active' ? 'Ativos' :
|
||||
selectedCategoryId === 'inactive' ? 'Inativos' :
|
||||
selectedCategoryId === 'watched' ? 'Vistos' :
|
||||
selectedCategoryId === 'unwatched' ? 'Não vistos' :
|
||||
categories.find(c => String(c.id) === selectedCategoryId)?.name ?? 'Todos'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu style={{ zIndex: 4000 }} className="text-center w-100">
|
||||
<Dropdown.Item eventKey="all" active={selectedCategoryId === 'all'}>Todos</Dropdown.Item>
|
||||
{!isAdmin && (
|
||||
<>
|
||||
<Dropdown.Item eventKey="watched" active={selectedCategoryId === 'watched'}>Vistos</Dropdown.Item>
|
||||
<Dropdown.Item eventKey="unwatched" active={selectedCategoryId === 'unwatched'}>Não vistos</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
{isAdmin === true && (
|
||||
<>
|
||||
<Dropdown.Item eventKey="active" active={selectedCategoryId === 'active'}>Ativos</Dropdown.Item>
|
||||
@@ -165,75 +180,91 @@ export default function Videos() {
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4">
|
||||
<Link to="/admin/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" /> Adicionar vídeo</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="row py-3 g-4">
|
||||
<div className="col-12 col-sm-6 text-start">
|
||||
<label htmlFor="filter" className="form-label fw-bold text-start">Filtrar vídeos</label>
|
||||
<div className="position-relative">
|
||||
<select className="form-control select-filter" name="filter" id="filter" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value)}>
|
||||
<option key="all" value="all">Todos</option>
|
||||
{isAdmin === true && (
|
||||
<>
|
||||
<option key="active" value="active">Ativos</option>
|
||||
<option key="inactive" value="inactive">Inativos</option>
|
||||
</>
|
||||
)}
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={String(category.id)}>{category.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="position-absolute top-50 end-0 translate-middle-y me-2"><LuChevronDown className="mb-1" /></span>
|
||||
</div>
|
||||
<span className="form-text text-muted"> Selecione um filtro para filtrar os vídeos</span>
|
||||
{isAdmin && (
|
||||
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4">
|
||||
<Link to="/admin/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" /> Adicionar vídeo</Link>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center">
|
||||
<Link to="/admin/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" />Adicionar vídeo</Link>
|
||||
</div>
|
||||
)}
|
||||
</div> */}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`${styles.containerVideos} mt-3`}>
|
||||
<div className="row g-3 p-0">
|
||||
{videos.length === 0 ? (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<span className="text-muted fs-5">Nenhum vídeo encontrado</span>
|
||||
</div>
|
||||
) : searchCompleted && videos.length > 0 ? (
|
||||
<>
|
||||
{filteredVideos.map((video) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
|
||||
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
|
||||
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
|
||||
{isAdmin && (
|
||||
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
|
||||
)}
|
||||
<img className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
|
||||
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
|
||||
<h2 className={`${styles.titleVideo} d-flex text-wrap mb-1`}>{video.title}</h2>
|
||||
{/* <span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span> */}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
{ loadingVideos ? (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
|
||||
</div>
|
||||
) : videosToShow.length > 0 ? (
|
||||
<div>
|
||||
<div className="row g-3 p-0">
|
||||
{videos.map((video) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2">
|
||||
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
|
||||
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
|
||||
{isAdmin && (
|
||||
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
|
||||
)}
|
||||
<img
|
||||
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
|
||||
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
|
||||
alt={video.title}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
|
||||
<h2 className={`${styles.titleVideo} d-flex text-wrap mb-1`}>{video.title}</h2>
|
||||
{/* <span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span> */}
|
||||
</div>
|
||||
{video.watched && <span className="d-block badge text-success text-start fs-6 position-absolute top-0 start-0 bg-success-subtle text-success px-3 py-2" style={{ borderRadius: "0 0 var(--border-radius) 0" }}><PiCheckCircleFill className="mb-1 me-1" /> Visto</span>}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="d-flex justify-content-center align-items-center gap-3 py-3">
|
||||
<Pagination>
|
||||
<Pagination.Prev
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => { setCurrentPage((p) => p - 1); setLoadingVideos(true); }}
|
||||
/>
|
||||
|
||||
{currentPage > 3 && <Pagination.Item onClick={() => { setCurrentPage(1); setLoadingVideos(true); }}>1</Pagination.Item>}
|
||||
{currentPage > 4 && <Pagination.Ellipsis disabled />}
|
||||
|
||||
{Array.from({ length: 5 }, (_, i) => currentPage - 2 + i)
|
||||
.filter((p) => p >= 1 && p <= lastPage)
|
||||
.map((p) => (
|
||||
<Pagination.Item key={p} active={p === currentPage} onClick={() => { setCurrentPage(p); setLoadingVideos(true); }}>
|
||||
{p}
|
||||
</Pagination.Item>
|
||||
))}
|
||||
|
||||
{currentPage < lastPage - 3 && <Pagination.Ellipsis disabled />}
|
||||
{currentPage < lastPage - 2 && (
|
||||
<Pagination.Item onClick={() => { setCurrentPage(lastPage); setLoadingVideos(true); }}>{lastPage}</Pagination.Item>
|
||||
)}
|
||||
|
||||
<Pagination.Next
|
||||
disabled={currentPage >= lastPage}
|
||||
onClick={() => { setCurrentPage((p) => p + 1); setLoadingVideos(true); }}
|
||||
/>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{videosToShow.length === 0 && (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<span className="text-muted fs-5">Nenhum vídeo encontrado com o filtro selecionado</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{ videos.length > 0 && filteredVideos.length === 0 && (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<span className="text-muted fs-5">Nenhum vídeo encontrado com o filtro selecionado</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
}
|
||||
|
||||
.boxVideo {
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -59,7 +62,11 @@
|
||||
}
|
||||
|
||||
.boxVideo::after {
|
||||
content: '';
|
||||
content: '\F4F4';
|
||||
color: var(--bg-grey);
|
||||
font-size: 4rem;
|
||||
align-content: center;
|
||||
font-family: 'bootstrap-icons';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -67,6 +74,11 @@
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
|
||||
z-index: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.boxVideo:hover::after {
|
||||
color: var(--bg-primary-color);
|
||||
}
|
||||
|
||||
.boxVideoInfo{
|
||||
@@ -78,7 +90,7 @@
|
||||
.titleVideo {
|
||||
color: var(--text-white);
|
||||
font-size: var(--size-font-text);
|
||||
font-weight: 700;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.descriptionVideo {
|
||||
@@ -100,6 +112,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import type { ApiErrorResponse, User, Workshop } from "../../../types";
|
||||
import { LuArrowLeft, LuCalendar, LuClock3 } from "react-icons/lu";
|
||||
import { LuArrowLeft, LuCalendar, LuClock3, LuUsers } from "react-icons/lu";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import Swal from "sweetalert2";
|
||||
import styles from "./styles.module.css";
|
||||
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser";
|
||||
import { usePreloadImages } from "../../../hooks/usePreloadImages";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
|
||||
|
||||
export default function Workshop() {
|
||||
const user = JSON.parse(localStorage.getItem("user") as unknown as string) as User;
|
||||
@@ -20,7 +20,6 @@ export default function Workshop() {
|
||||
const [error, setError] = useState<ApiErrorResponse | null>(null);
|
||||
const { getCurrentUser } = useGetCurrentUser();
|
||||
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
|
||||
const { preloadImages } = usePreloadImages();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
@@ -31,15 +30,9 @@ export default function Workshop() {
|
||||
getCurrentUser(),
|
||||
]);
|
||||
|
||||
setWorkshop(workshopData as Workshop); // workshopData já é o Workshop
|
||||
setWorkshop(workshopData as Workshop);
|
||||
setCurrentUserData(currentUserData.data);
|
||||
|
||||
if (workshopData) {
|
||||
await preloadImages([
|
||||
`http://127.0.0.1:8000/storage/${(workshopData as Workshop).image}`
|
||||
]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -60,7 +53,7 @@ export default function Workshop() {
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return data.data as Workshop; // ← um único Workshop, não array
|
||||
return data.data as Workshop;
|
||||
} else {
|
||||
setError(data as ApiErrorResponse);
|
||||
return null;
|
||||
@@ -159,48 +152,59 @@ export default function Workshop() {
|
||||
<span className="text-muted fs-5">{error.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ workshop && (
|
||||
|
||||
{workshop && (
|
||||
<>
|
||||
<div className={`${styles.container} d-flex flex-column gap-2`}>
|
||||
<div className="row mt-4 g-3 gx-md-4 gx-lg-5 ms-0">
|
||||
<div className={`${styles.thumbnailWrapper} col-12 col-lg-3 align-self-center align-self-sm-center px-0 mt-0`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={`${styles.thumbnail} w-100`}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-lg-9 d-flex flex-column justify-content-between gap-2 mt-3 mt-lg-0">
|
||||
<div className="d-flex flex-column flex-wrap gap-1 justify-content-center justify-content-md-start">
|
||||
<h2 className={`${styles.title} text-start d-inline-block`}>{workshop.title}</h2>
|
||||
|
||||
<span className={styles.title}>{workshop.title}</span>
|
||||
<p className={`${styles.description} text-start mb-3`}>{workshop.description}</p>
|
||||
|
||||
<div className="d-flex flex-column flex-md-row mt-4 gap-2 gap-md-4 gap-lg-5">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={`${styles.thumbnail} align-self-center align-self-sm-center`} />
|
||||
<div className="d-flex flex-column justify-content-center gap-2">
|
||||
<div className="d-flex flex-column flex-wrap
|
||||
gap-3 justify-content-center justify-content-md-start mb-2">
|
||||
<div className="d-flex flex-wrap flex-md-column text-start gap-1 mt-2">
|
||||
<span className={`${styles.dateWorkshop} text-start d-inline-block`}>
|
||||
<LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} />{workshop.date.split("-").reverse().join("-")}
|
||||
</span>
|
||||
|
||||
<span className={`${styles.timeWorkshop} text-start d-inline-block`}>
|
||||
<LuClock3 className={`${styles.iconClock} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
<div className={`${styles.dateWorkshop} text-start d-inline-block px-3 py-2`}>
|
||||
<small className="d-block text-muted mb-1 me-2"><LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} /> Dia</small>
|
||||
{workshop.date.split("-").reverse().join("-")}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto ms-sm-0">
|
||||
{!isAdmin ? (
|
||||
currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
|
||||
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
Anular inscrição
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
Inscrever-me
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
<div className={`${styles.timeWorkshop} text-start d-inline-block px-3 py-2`}>
|
||||
<small className="d-block text-muted mb-1 me-2"><LuClock3 className={`${styles.iconClock} mb-1 me-2`} /> Hora</small>
|
||||
{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
|
||||
</div>
|
||||
|
||||
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
|
||||
<small className="d-block text-muted mb-1 me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
|
||||
<span className={`${styles.contagemUtilizadoresInscritos} fs-6 px-0`}>{workshop.users.length === 1 ? "1 utilizador" : `${workshop.users.length} utilizadores`} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 mx-auto ms-sm-0 mt-5 pt-3">
|
||||
{!isAdmin ? (
|
||||
currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
|
||||
<button type="button" className={`${styles.btncancelarInscricao} btn text-center mx-auto py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
Anular inscrição
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={`${styles.btnInscrever} btn text-center mx-auto py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
Inscrever-me
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-column gap-2 justify-content-center mt-3">
|
||||
<p className={`${styles.description} text-start mb-3`}>{workshop.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
.LinkIcon, .linkText{
|
||||
color: var(--text-black);
|
||||
font-size: var(--size-font-text);
|
||||
}
|
||||
|
||||
.button:hover .LinkIcon, .button:hover .linkText{
|
||||
@@ -15,6 +16,7 @@
|
||||
.title{
|
||||
color: var(--primary-contrast-color);
|
||||
font-size: var(--size-font-title);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.subtitle{
|
||||
@@ -22,15 +24,24 @@
|
||||
font-size: var(--size-font-subtitle);
|
||||
}
|
||||
|
||||
.thumbnailWrapper{
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
height: 250px;
|
||||
border-radius: var(--border-radius);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumbnail{
|
||||
width: 300px;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.dateWorkshop, .timeWorkshop{
|
||||
background-color: var(--bg-primary-color-opacity);
|
||||
background-color: var(--bg-white);
|
||||
color: var(--text-black);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
@@ -186,6 +197,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -4,11 +4,12 @@ import { Link } from "react-router";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import styles from "./styles.module.css";
|
||||
import { LuPlus, LuCalendar, LuClock3, LuSettings2 } from "react-icons/lu";
|
||||
import { usePreloadImages } from "../../../hooks/usePreloadImages";
|
||||
import { useGetWorkshops } from "../../../hooks/useGetWorkshops";
|
||||
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser";
|
||||
import { useDebounce } from "../../../hooks/useDebounce";
|
||||
import Swal from "sweetalert2";
|
||||
import { Dropdown } from "react-bootstrap";
|
||||
import { Dropdown, Form, Pagination } from "react-bootstrap";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
|
||||
|
||||
export default function Workshops() {
|
||||
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
|
||||
@@ -16,42 +17,51 @@ export default function Workshops() {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedWorkshopStatus, setSelectedWorkshopStatus] = useState<string>("pending");
|
||||
const { preloadImages } = usePreloadImages();
|
||||
const { getWorkshops } = useGetWorkshops();
|
||||
const [workshops, setWorkshops] = useState<Workshop[]>([]);
|
||||
const { getCurrentUser } = useGetCurrentUser();
|
||||
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [loadingWorkshops, setLoadingWorkshops] = useState(false);
|
||||
/* const [searchLoading, setSearchLoading] = useState(false); */
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
setLoading(true);
|
||||
const fetchWorkshops = async () => {
|
||||
setLoadingWorkshops(true);
|
||||
|
||||
const [workshopsData, currentUserData] = await Promise.all([
|
||||
getWorkshops(),
|
||||
getCurrentUser(),
|
||||
]);
|
||||
try {
|
||||
const currentUserData = await getCurrentUser();
|
||||
setCurrentUserData(currentUserData.data);
|
||||
|
||||
setWorkshops(workshopsData as Workshop[]);
|
||||
setCurrentUserData(currentUserData?.data as User);
|
||||
console.log("workshops response:", workshopsData);
|
||||
const workshopsData = await getWorkshops({
|
||||
page: currentPage,
|
||||
search: debouncedSearch,
|
||||
status: selectedWorkshopStatus,
|
||||
});
|
||||
|
||||
await preloadImages([
|
||||
...(workshopsData as Workshop[]).map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
|
||||
]);
|
||||
if ("workshops" in workshopsData) {
|
||||
setWorkshops(workshopsData.workshops);
|
||||
setLastPage(workshopsData.meta.last_page);
|
||||
setCurrentPage(workshopsData.meta.current_page);
|
||||
setLoadingWorkshops(false);
|
||||
} else {
|
||||
setWorkshops([]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch {
|
||||
setWorkshops([]);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingWorkshops(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
}, []);
|
||||
|
||||
const filteredWorkshops = workshops.filter((workshop) => {
|
||||
if (selectedWorkshopStatus === "all") return true;
|
||||
if (selectedWorkshopStatus === "pending") return workshop.status === "pending";
|
||||
if (selectedWorkshopStatus === "realized") return workshop.status === "realized";
|
||||
if (selectedWorkshopStatus === "canceled") return workshop.status === "canceled";
|
||||
if (selectedWorkshopStatus === "inscrito") return currentUserData && workshop.status === "pending" && workshop.users.some((user: User) => user.id === currentUserData.id);
|
||||
});
|
||||
fetchWorkshops();
|
||||
}, [selectedWorkshopStatus, debouncedSearch, currentPage]);
|
||||
|
||||
/* Inscrever num workshop */
|
||||
async function inscrever(workshopId: number) {
|
||||
@@ -73,8 +83,18 @@ export default function Workshops() {
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
});
|
||||
const workshops = await getWorkshops();
|
||||
setWorkshops(workshops as Workshop[]);
|
||||
const workshopsData = await getWorkshops({
|
||||
page: currentPage,
|
||||
search: debouncedSearch,
|
||||
status: selectedWorkshopStatus,
|
||||
});
|
||||
if ("workshops" in workshopsData) {
|
||||
setWorkshops(workshopsData.workshops);
|
||||
setLastPage(workshopsData.meta.last_page);
|
||||
setCurrentPage(workshopsData.meta.current_page);
|
||||
} else {
|
||||
setWorkshops([]);
|
||||
}
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: data.message,
|
||||
@@ -105,15 +125,23 @@ export default function Workshops() {
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
});
|
||||
const workshops = await getWorkshops();
|
||||
setWorkshops(workshops as Workshop[]);
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: data.message,
|
||||
icon: 'error',
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
const workshopsData = await getWorkshops({
|
||||
page: currentPage,
|
||||
search: debouncedSearch,
|
||||
status: selectedWorkshopStatus,
|
||||
});
|
||||
if ("workshops" in workshopsData) {
|
||||
setWorkshops(workshopsData.workshops);
|
||||
setLastPage(workshopsData.meta.last_page);
|
||||
setCurrentPage(workshopsData.meta.current_page);
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: data.message,
|
||||
icon: 'error',
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,125 +154,172 @@ export default function Workshops() {
|
||||
)
|
||||
}
|
||||
|
||||
if (workshops.length === 0) {
|
||||
return (
|
||||
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
<span>Nenhum workshop encontrado</span>
|
||||
{isAdmin && (
|
||||
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className={`${styles.container} p-4 p-lg-0`}>
|
||||
<h1 className={`${styles.title} mt-1 mb-0 mb-sm-3`}>Workshops</h1>
|
||||
return (
|
||||
<div className={`${styles.container} p-4 p-lg-0`}>
|
||||
<h1 className={`${styles.title} mt-1 mb-0 mb-sm-3`}>Workshops</h1>
|
||||
|
||||
<div className="row pt-3 justify-content-between d-flex flex-column-reverse flex-sm-row">
|
||||
<div className="col-12 col-sm-5 col-lg-3 text-start px-2 mt-4 mt-sm-3 ">
|
||||
{/* <label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label> */}
|
||||
<Dropdown onSelect={(value) => {
|
||||
if (value) setSelectedWorkshopStatus(value);
|
||||
}}>
|
||||
<Dropdown.Toggle variant="outline-secondary" className="w-100 text-center" style={{ maxWidth: '205px' }}>
|
||||
<LuSettings2 /> {selectedWorkshopStatus === 'all' ? 'Todos' : selectedWorkshopStatus === 'pending' ? 'Agendados' : selectedWorkshopStatus === 'inscrito' ? 'Inscrito' : selectedWorkshopStatus === 'realized' ? 'Realizados' : 'Cancelados'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="text-center w-100" style={{ maxWidth: '205px' }}>
|
||||
<Dropdown.Item eventKey="all" active={selectedWorkshopStatus === 'all'}>
|
||||
Todos
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item eventKey="pending" active={selectedWorkshopStatus === 'pending'}>
|
||||
Agendados
|
||||
</Dropdown.Item>
|
||||
{!isAdmin && (
|
||||
<Dropdown.Item eventKey="inscrito" active={selectedWorkshopStatus === 'inscrito'}>
|
||||
Inscrito
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item eventKey="realized" active={selectedWorkshopStatus === 'realized'}>
|
||||
Realizados
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item eventKey="canceled" active={selectedWorkshopStatus === 'canceled'}>
|
||||
Cancelados
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center pe-sm-2 mt-sm-3" style={{ minHeight: '40px' }}>
|
||||
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="row py-3 g-4 justify-content-between">
|
||||
<div className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2">
|
||||
<Form.Control type="text" placeholder="Pesquisar workshops..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
|
||||
{/* <label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label> */}
|
||||
<Dropdown className="flex-grow-1" onSelect={(value) => {
|
||||
setCurrentPage(1);
|
||||
if (value) setSelectedWorkshopStatus(value);
|
||||
}}>
|
||||
<Dropdown.Toggle variant="outline-secondary" className="w-100 text-center" >
|
||||
<LuSettings2 /> {selectedWorkshopStatus === 'pending' ? 'Agendados' : selectedWorkshopStatus === 'inscrito' ? 'Inscrito' : selectedWorkshopStatus === 'realized' ? 'Realizados' : 'Cancelados'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="text-center w-100" >
|
||||
<Dropdown.Item eventKey="pending" active={selectedWorkshopStatus === 'pending'}>
|
||||
Agendados
|
||||
</Dropdown.Item>
|
||||
{!isAdmin && (
|
||||
<Dropdown.Item eventKey="inscrito" active={selectedWorkshopStatus === 'inscrito'}>
|
||||
Inscrito
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item eventKey="realized" active={selectedWorkshopStatus === 'realized'}>
|
||||
Realizados
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item eventKey="canceled" active={selectedWorkshopStatus === 'canceled'}>
|
||||
Cancelados
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4" style={{ minHeight: '40px' }}>
|
||||
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="row py-3 g-4">
|
||||
{filteredWorkshops.map((workshop) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
|
||||
<div className={`${styles.boxWorkshop} text-start pb-3`}>
|
||||
<div className="position-relative">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
|
||||
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
||||
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
||||
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
||||
<div className="row py-3 g-4">
|
||||
{loadingWorkshops ? (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
|
||||
</div>
|
||||
) : workshops.length > 0 ? (
|
||||
<>
|
||||
{workshops.map((workshop) => (
|
||||
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
|
||||
<div className={`${styles.boxWorkshop} text-start pb-3`}>
|
||||
<div className={`${styles.thumbnailWorkshop} position-relative`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={styles.thumbnailWorkshop}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
||||
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
||||
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
|
||||
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
|
||||
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
|
||||
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
|
||||
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
|
||||
<div className="px-3">
|
||||
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
|
||||
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
|
||||
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
|
||||
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
|
||||
|
||||
{!isAdmin && workshop.status === "pending" ? (
|
||||
<>
|
||||
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
{currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
|
||||
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
|
||||
{workshop.status === "realized" ? (
|
||||
<>
|
||||
<span className="text-success fw-bold text-center py-2 mb-0">Workshop realizado</span>
|
||||
{isAdmin && (
|
||||
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
)}
|
||||
</>
|
||||
) : workshop.status === "canceled" ? (
|
||||
<>
|
||||
<span className="text-danger fw-bold text-center py-2 mb-0">Workshop cancelado</span>
|
||||
{isAdmin && (
|
||||
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
)}
|
||||
</>
|
||||
) : workshop.status === "pending" && currentUserData && workshop.users.some((u: User) => u.id === currentUserData.id) ? (
|
||||
<>
|
||||
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => {
|
||||
cancelarInscricao(workshop.id); getWorkshops({
|
||||
page: currentPage,
|
||||
search: debouncedSearch,
|
||||
status: selectedWorkshopStatus,
|
||||
});
|
||||
}} key={workshop.id} style={{ width: '180px' }}>
|
||||
Anular inscrição
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
|
||||
</>
|
||||
) : isAdmin && workshop.status === "pending" ? (
|
||||
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
) : workshop.status === "pending" && !isAdmin ? (
|
||||
<>
|
||||
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => {
|
||||
inscrever(workshop.id); getWorkshops({
|
||||
page: currentPage,
|
||||
search: debouncedSearch,
|
||||
status: selectedWorkshopStatus,
|
||||
});
|
||||
}} key={workshop.id} style={{ width: '180px' }}>
|
||||
Inscrever-me
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : workshop.status === "realized" ? (
|
||||
<>
|
||||
<span className="text-success fw-bold text-center py-2 mb-0">Workshop realizado</span>
|
||||
{isAdmin ? (
|
||||
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
) : null}
|
||||
</>
|
||||
) : workshop.status === "canceled" ? (
|
||||
<>
|
||||
<span className="text-danger fw-bold text-center py-2 mb-0">Workshop cancelado</span>
|
||||
{isAdmin ? (
|
||||
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
) : null}
|
||||
</>
|
||||
) : isAdmin ? (
|
||||
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="d-flex justify-content-center align-items-center gap-3 py-3">
|
||||
<Pagination>
|
||||
<Pagination.Prev
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => { setCurrentPage((p) => p - 1); setLoadingWorkshops(true); }}
|
||||
/>
|
||||
|
||||
))}
|
||||
{selectedWorkshopStatus === "pending" && filteredWorkshops.length === 0 ? (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<span className="text-muted fs-5">Sem workshops agendados</span>
|
||||
{currentPage > 3 && <Pagination.Item onClick={() => { setCurrentPage(1); setLoadingWorkshops(true); }}>1</Pagination.Item>}
|
||||
{currentPage > 4 && <Pagination.Ellipsis disabled />}
|
||||
|
||||
{Array.from({ length: 5 }, (_, i) => currentPage - 2 + i)
|
||||
.filter((p) => p >= 1 && p <= lastPage)
|
||||
.map((p) => (
|
||||
<Pagination.Item key={p} active={p === currentPage} onClick={() => { setCurrentPage(p); setLoadingWorkshops(true); }}>
|
||||
{p}
|
||||
</Pagination.Item>
|
||||
))}
|
||||
|
||||
{currentPage < lastPage - 3 && <Pagination.Ellipsis disabled />}
|
||||
{currentPage < lastPage - 2 && (
|
||||
<Pagination.Item onClick={() => { setCurrentPage(lastPage); setLoadingWorkshops(true); }}>{lastPage}</Pagination.Item>
|
||||
)}
|
||||
|
||||
<Pagination.Next
|
||||
disabled={currentPage >= lastPage}
|
||||
onClick={() => { setCurrentPage((p) => p + 1); setLoadingWorkshops(true); }}
|
||||
/>
|
||||
</Pagination>
|
||||
</div>
|
||||
) : filteredWorkshops.length === 0 ? (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<span className="text-muted fs-5">Nenhum workshop encontrado com o filtro selecionado</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
) : selectedWorkshopStatus === "pending" && workshops.length === 0 ? (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<span className="text-muted fs-5">Sem workshops agendados</span>
|
||||
</div>
|
||||
) : workshops.length === 0 ? (
|
||||
<div className="col-12 text-center mt-5">
|
||||
<span className="text-muted fs-5">Nenhum workshop encontrado com o filtro selecionado</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -112,18 +112,23 @@
|
||||
}
|
||||
|
||||
.titleWorkshop{
|
||||
color: var(--text-primary-color);
|
||||
color: var(--primary-contrast-color);
|
||||
font-size: var(--size-font-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.thumbnailWorkshop{
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon{
|
||||
@@ -134,6 +139,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
from{
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -52,6 +52,13 @@ export type CreateVideoResponse = {
|
||||
data?: Video;
|
||||
}
|
||||
|
||||
export type PaginationMeta = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type Video = {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -64,16 +71,23 @@ export type Video = {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active: boolean;
|
||||
order: number;
|
||||
watched: boolean;
|
||||
}
|
||||
|
||||
export type NextVideosResponse = {
|
||||
videos: Video[];
|
||||
};
|
||||
|
||||
export type UpdateVideoResponse = {
|
||||
message?: string;
|
||||
data?: Video;
|
||||
}
|
||||
|
||||
export type GetVideoResponse = {
|
||||
export type GetVideosResponse = {
|
||||
message?: string;
|
||||
data?: Video;
|
||||
data?: Video[];
|
||||
meta?: PaginationMeta;
|
||||
}
|
||||
|
||||
export type CreateCategoryResponse = {
|
||||
@@ -100,6 +114,19 @@ export type Workshop = {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export type NextWorkshopsResponse = {
|
||||
workshops: Workshop[];
|
||||
};
|
||||
|
||||
export type GetWorkshopsResponse = {
|
||||
data?: Workshop[];
|
||||
meta?: {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type getWorkshop = {
|
||||
message?: string;
|
||||
data?: Workshop;
|
||||
|
||||
10
frontend-plataforma-tutoriais/src/utils/imageSkeleton.ts
Normal file
10
frontend-plataforma-tutoriais/src/utils/imageSkeleton.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CSSProperties, SyntheticEvent } from "react";
|
||||
|
||||
export const imageSkeletonFadeStyle: CSSProperties = {
|
||||
opacity: 0,
|
||||
transition: "opacity 0.3s",
|
||||
};
|
||||
|
||||
export function onImageSkeletonLoad(e: SyntheticEvent<HTMLImageElement>) {
|
||||
e.currentTarget.style.opacity = "1";
|
||||
}
|
||||
Reference in New Issue
Block a user