feat: paginate workshops and videos pages

This commit is contained in:
Xavier Oliveira
2026-05-27 09:24:10 +01:00
parent da0baaee15
commit 68f99798ce
72 changed files with 3352 additions and 1044 deletions

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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'}}
/>
);
}

View File

@@ -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;
}

View File

@@ -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>
{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>
<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">
<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>
</ul>
</motion.ul>
)}
</AnimatePresence>
</div>
</div>
</nav>

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 ?? "")}`, {
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;
}
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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);
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View 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 };
}

View 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 };
}

View File

@@ -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 };
}

View 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 };
}

View File

@@ -14,11 +14,10 @@
--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;
@@ -47,10 +46,14 @@
--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);;
--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;
@@ -122,20 +125,24 @@ h1 {
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;
}
}
p {
margin: 0;
}
@@ -173,6 +180,3 @@ button:focus {
box-shadow: none !important;
border: none;
}

View File

@@ -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(

View File

@@ -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>
</>
);

View File

@@ -24,6 +24,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{

View File

@@ -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>

View File

@@ -6,6 +6,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{

View File

@@ -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)}

View File

@@ -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%;

View File

@@ -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",
@@ -256,27 +259,31 @@ export default function editVideo() {
<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>
<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="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" defaultValue={video?.description} />
</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>
<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="form-label fw-bold" htmlFor="thumbnail">Atualizar thumbnail</label>
<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>
<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="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
<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>
@@ -296,7 +303,7 @@ export default function editVideo() {
</div>
</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>
<label className={`${styles.label} form-label fw-bold`} htmlFor="tags">Estado</label>
<div className="d-flex gap-4">
<span>
<input
@@ -321,13 +328,9 @@ export default function editVideo() {
<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="buttonsContainer mt-5 d-flex gap-2 justify-content-center" >
<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>

View File

@@ -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;

View File

@@ -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;
@@ -205,80 +205,64 @@ 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>
<h2 className={`${styles.title} text-start d-inline-block`}>{workshop.title}</h2>
<p className={`${styles.description} text-start`}>{workshop.description}</p>
</div>
<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="">
<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 utilizadores inscritos neste workshop</span>
</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"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}>0 utilizadores</span>
</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)
<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 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>
{!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}
</div>
) : null}
<div>
<div className={`${styles.users} mt-4`}>
{listagemInscritos ? (
<div className={`${styles.users} mt-4`}>
<div className="table-responsive">
<button type="button" className={`${styles.btnClose} d-flex float-end`} onClick={() => setListagemInscritos(false)}><LuX className={`${styles.iconClose} mb-1`} /> </button>
<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>
@@ -310,25 +294,51 @@ export default function Workshop() {
</tbody>
</table>
</div>
</div>
) : null}
</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>
</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>

View File

@@ -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);

View File

@@ -24,6 +24,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{

View File

@@ -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(),
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);
}
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>
<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>
<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>
</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>

View File

@@ -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);
@@ -75,6 +81,7 @@
background-color: var(--bg-grey);
color: var(--text-black);
transition: all .3s ease;
&:hover {
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
@@ -91,6 +98,7 @@
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
transition: all .3s ease;
&:hover {
background-color: var(--bg-primary-color);
color: var(--text-white);
@@ -107,6 +115,7 @@
background-color: var(--tertiary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover {
background-color: var(--bg-tertiary-color-opacity);
color: var(--text-tertiary-color);
@@ -122,7 +131,7 @@
overflow: visible;
color: var(--text-primary-color);
font-weight: 800;
background-color: var(--bg-primary-color);
background-color: var(--bg-primary-color-opacity);
}
.iconEdit {
@@ -138,6 +147,7 @@
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&: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,6 +195,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 {
@@ -218,6 +251,7 @@
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background-color: var(--bg-primary-color-opacity);
color: var(--primary-color) !important;
@@ -227,7 +261,7 @@
.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);

View File

@@ -48,7 +48,7 @@
.userVideosWatched{
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
background-color: var(--bg-neutral-color);
}
.closeFormButton{

View File

@@ -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>

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>
</div>
)
}
const playerSkeleton = (
<div className={`${styles.playerContent} ${styles.videoPlayerSkeleton}`} aria-hidden="true" />
);
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>
) : (
<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 ? (
const pageSkeleton = (
<div className="my-3 d-flex flex-column gap-2">
<span className={`${styles.title}`}>{video.title}</span>
<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>
);
<div style={{ maxWidth: "1000px", margin: "0 auto", display: playerReady ? 'block' : 'none' }}>
return (
<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>
{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>
<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>
)}
</>
)
);
}

View File

@@ -9,11 +9,14 @@
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);
}
@@ -25,7 +28,113 @@
from {
transform: rotate(0deg);
}
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;
}
}

View File

@@ -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", {
@@ -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(),
]);
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 (Array.isArray(videosData)) {
setVideos(videosData);
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,7 +128,7 @@ 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">
@@ -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>
{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>
</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>
</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 className={`${styles.containerVideos} mt-3`}>
<div className="row g-3 p-0">
{videos.length === 0 ? (
{ loadingVideos ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum vídeo encontrado</span>
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
) : searchCompleted && videos.length > 0 ? (
<>
{filteredVideos.map((video) => (
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
) : 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} />
<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 className="col-12 text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</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>
)}
{ videos.length > 0 && filteredVideos.length === 0 && (
<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>
)}
</>
)}
</div>
</div>
</div>

View File

@@ -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);

View File

@@ -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;
@@ -163,44 +156,55 @@ export default function 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">
<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 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 py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
<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 py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
<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>
</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>
</div>
</>

View File

@@ -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);

View File

@@ -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([]);
}
} 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,8 +125,15 @@ 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 {
Swal.fire({
title: data.message,
@@ -116,6 +143,7 @@ export default function Workshops() {
});
}
}
}
if (loading) {
return (
@@ -126,33 +154,27 @@ 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>
<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 ">
<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 onSelect={(value) => {
<Dropdown className="flex-grow-1" onSelect={(value) => {
setCurrentPage(1);
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 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" style={{ maxWidth: '205px' }}>
<Dropdown.Item eventKey="all" active={selectedWorkshopStatus === 'all'}>
Todos
</Dropdown.Item>
<Dropdown.Menu className="text-center w-100" >
<Dropdown.Item eventKey="pending" active={selectedWorkshopStatus === 'pending'}>
Agendados
</Dropdown.Item>
@@ -171,18 +193,30 @@ export default function Workshops() {
</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' }}>
<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) => (
{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="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>
@@ -190,7 +224,6 @@ export default function Workshops() {
</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>
@@ -198,47 +231,89 @@ export default function Workshops() {
</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' }}>
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' }}>
Inscrever-me
</button>
)}
</>
) : workshop.status === "realized" ? (
{workshop.status === "realized" ? (
<>
<span className="text-success fw-bold text-center py-2 mb-0">Workshop realizado</span>
{isAdmin ? (
{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 ? (
{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 ? (
) : 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>
</>
) : 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>
</>
) : null}
</div>
</div>
</div>
))}
{selectedWorkshopStatus === "pending" && filteredWorkshops.length === 0 ? (
<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); }}
/>
{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>
</>
) : selectedWorkshopStatus === "pending" && workshops.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Sem workshops agendados</span>
</div>
) : filteredWorkshops.length === 0 ? (
) : 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>
@@ -247,4 +322,4 @@ export default function Workshops() {
</div>
)
}
}

View File

@@ -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);

View File

@@ -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;

View 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";
}

View File

@@ -1,11 +1,27 @@
* text=auto eol=lf
# Diffs
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
*.ts diff=typescript
*.tsx diff=typescript
# Exports
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
# Binary files - no conversion
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary

View File

@@ -108,8 +108,6 @@ class AuthController extends Controller
'message' => 'Utilizador obtido com sucesso',
'data' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role_id' => $user->role_id,
],
'errors' => null,

View File

@@ -10,7 +10,7 @@ class CategoryController extends Controller
{
public function index()
{
$categories = Category::all();
$categories = Category::select('id', 'name', 'is_active')->orderBy('name', 'asc')->get();
return response()->json([
'message' => 'Categorias obtidas com sucesso',
@@ -21,7 +21,7 @@ class CategoryController extends Controller
public function create(CreateCategoryRequest $request)
{
$category = Category::create($request->all());
$category = Category::create($request->validated());
return response()->json([
'message' => 'Categoria criada com sucesso',
'data' => $category,

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\Video;
use App\Models\VideoView;
use Illuminate\Http\Request;
class VideoViewController extends Controller
{
public function store(Video $video) {
$user = auth()->user();
//firstOrCreate só cria se não existir, se existir, atualiza o watched_at
VideoView::firstOrCreate([
'user_id' => $user->id,
'video_id' => $video->id,
], [
'watched_at' => now(),
]);
return response()->json(['watched' => true]);
}
}

View File

@@ -30,37 +30,191 @@ class VideosController extends Controller
return response()->json([
'message' => 'Utilizador não autenticado',
'data' => null,
'errors' => null,
], 404);
], 401);
}
$search = trim((string) $request->query('search', ''));
$videosQuery = Video::with('categories');
$categoryId = $request->query('category');
$watched = $request->query('watched');
$perPage = $request->query('per_page', 9);
if ($search !== '') {
$videosQuery->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%")
$query = Video::select(['id', 'title', 'thumbnail', 'is_active'])
->with([
'categories:id,name',
'views' => function ($q) use ($user) {
$q->select('id', 'video_id', 'user_id')
->where('user_id', $user->id);
}
])
->when($search !== '', function ($q) use ($search) {
$q->where(function ($sub) use ($search) {
$sub->where('title', 'like', "%{$search}%")
->orWhere('tags', 'like', "%{$search}%");
});
})
->when($categoryId, function ($q) use ($categoryId) {
$q->whereHas('categories', function ($c) use ($categoryId) {
$c->where('categories.id', $categoryId);
});
})
->when($user->role_id !== 1, function ($q) {
$q->where('is_active', true);
})
->when($watched !== null, function ($q) use ($watched, $user) {
if ((int) $watched === 1) {
$q->whereHas('views', function ($v) use ($user) {
$v->where('user_id', $user->id);
});
}
if ($user->role_id !== 1) {
$videosQuery->where('is_active', true);
if ((int) $watched === 0) {
$q->whereDoesntHave('views', function ($v) use ($user) {
$v->where('user_id', $user->id);
});
}
})
$videos = $videosQuery->get();
->orderByRaw('
CASE WHEN EXISTS (
SELECT 1 FROM video_views
WHERE video_views.video_id = videos.id
AND video_views.user_id = ?
) THEN 1 ELSE 0 END ASC,
videos.order ASC
', [$user->id])
->paginate($perPage);
if ($videos->isEmpty()) {
return response()->json([
'message' => $search !== '' ? 'Sem resultados para a pesquisa' : 'Não foram encontrados vídeos',
'data' => [],
'errors' => null,
], $search !== '' ? 200 : 404); // 200 se for pesquisa, 404 se for listagem normal
}
$query->getCollection()->transform(function ($video) {
return [
'id' => $video->id,
'title' => $video->title,
'thumbnail' => $video->thumbnail,
'is_active' => $video->is_active,
'categories' => $video->categories,
'watched' => $video->views->isNotEmpty(),
];
});
return response()->json([
'message' => 'Vídeos obtidos com sucesso',
'data' => $query->items(),
'meta' => [
'current_page' => $query->currentPage(),
'last_page' => $query->lastPage(),
'per_page' => $query->perPage(),
'total' => $query->total(),
],
]);
}
public function nextVideos()
{
$user = auth()->user();
if (!$user) {
return response()->json([
'message' => 'Não autenticado',
], 401);
}
$videos = Video::select(['id', 'title', 'thumbnail', 'is_active', 'order'])
->with(['categories:id,name'])
// 👇 regra base: vídeos não vistos
->whereDoesntHave('views', function ($q) use ($user) {
$q->where('user_id', $user->id);
})
// 👇 users normais não veem inativos
->when($user->role_id !== 1, function ($q) {
$q->where('is_active', true);
})
->orderBy('order', 'asc')
->limit(3)
->get()
->map(function ($video) {
return [
'id' => $video->id,
'title' => $video->title,
'thumbnail' => $video->thumbnail,
'is_active' => $video->is_active,
'categories' => $video->categories,
'watched' => false, // aqui já sabes que não foram vistos
];
});
return response()->json([
'message' => 'Dashboard videos',
'data' => $videos,
]);
}
public function search(Request $request)
{
$user = auth()->user();
$search = trim((string) $request->query('search', ''));
$videos = Video::select(['id', 'title', 'thumbnail', 'is_active'])
->with([
'categories:id,name',
'views' => function ($query) use ($user) {
$query->select('id', 'video_id', 'user_id')
->where('user_id', $user->id);
}
])
->when($user->role_id !== 1, function ($query) {
$query->where('is_active', true);
})
->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%")
->orWhere('tags', 'like', "%{$search}%");
})
->limit(20)
->get()
->map(function ($video) {
return [
'id' => $video->id,
'title' => $video->title,
'thumbnail' => $video->thumbnail,
'is_active' => $video->is_active,
'categories' => $video->categories,
'watched' => $video->views->isNotEmpty(),
];
});
return response()->json([
'message' => 'Resultados obtidos com sucesso',
'data' => $videos,
]);
}
public function videosLength()
{
$user = auth()->user();
$userID = $user->id;
if ($user->role_id !== 1) {
$videos = Video::select('id')->with('views')->where('is_active', true)->count();
$videosWatched = Video::with('views')->where('is_active', true)->whereHas('views', function ($query) use ($userID) {
$query->where('user_id', $userID);
})->count();
} else {
$videos = Video::select('id')->with('views')->where('is_active', true)->count();
}
return response()->json([
'message' => 'Vídeos ativos obtidos com sucesso',
'data' => [
'videos' => $videos,
'videosWatched' => $videosWatched ?? 0,
],
'errors' => null,
], 200);
}
@@ -77,8 +231,18 @@ class VideosController extends Controller
], 404);
}
$userID = $user->id;
if ($user->role_id !== 1) {
$video = Video::with('categories')->where('is_active', true)->find($id);
/* $video = Video::with('categories')->where('is_active', true)->find($id); */
$video = Video::with([
'categories' => function ($query) use ($userID) {
$query->where('is_active', true);
},
'views' => function ($query) use ($userID) {
$query->where('user_id', $userID);
}
])->find($id);
/* Para não mostrar vídeos inactivos para utilizadores não administradores */
if (!$video || $video->is_active === false) {
@@ -106,6 +270,7 @@ class VideosController extends Controller
'thumbnail' => $video->thumbnail,
'duration' => $video->duration,
'tags' => $video->tags,
'order' => $video->order,
'categories' => $video->categories->map(function ($category) {
return [
'id' => $category->id,
@@ -113,6 +278,7 @@ class VideosController extends Controller
];
})->values(),
'is_active' => $video->is_active,
'watched' => $video->views->isNotEmpty(),
],
'errors' => null,
], 200);
@@ -150,6 +316,7 @@ class VideosController extends Controller
'thumbnail' => $thumbnailPath,
'duration' => $validated['duration'] ?? '00:00',
'tags' => $validated['tags'],
'order' => $validated['order'],
]);
if ($request->filled('category_ids')) {
@@ -159,7 +326,7 @@ class VideosController extends Controller
$baseUrl = $request->getSchemeAndHttpHost();
return response()->json([
'message' => 'Vídeo criado com sucesso',
'message' => 'Vídeo adicionado com sucesso',
'data' => [
'id' => $video->id,
'title' => $video->title,
@@ -168,6 +335,7 @@ class VideosController extends Controller
'thumbnail' => $baseUrl . Storage::url($video->thumbnail),
'duration' => $video->duration,
'tags' => $video->tags,
'order' => $video->order,
'categories' => $video->categories->pluck('name'),
'is_active' => $video->is_active,
],
@@ -215,6 +383,7 @@ class VideosController extends Controller
'tags' => $validated['tags'] ?? $videoToUpdate->tags,
'thumbnail' => $validated['thumbnail'] ?? $videoToUpdate->thumbnail,
'is_active' => array_key_exists('is_active', $validated) ? $validated['is_active'] : $videoToUpdate->is_active,
'order' => $validated['order'] ?? $videoToUpdate->order,
]);
if ($request->has('category_ids')) {

View File

@@ -24,49 +24,153 @@ class WorkshopsController extends Controller
$search = trim((string) $request->query('search', ''));
$status = trim((string) $request->query('status', ''));
$workshopsQuery = Workshop::with('users');
if ($search !== '') {
$workshopsQuery->where('title', 'like', "%{$search}%")->where('status', 'pending');
}
if ($user->role_id !== 1) {
$workshopsQuery->where(function ($query) use ($user) {
$query->where('status', 'pending')
->orWhere(function ($q) use ($user) {
$q->whereIn('status', ['pending'])
$perPage = $request->query('per_page', 9);
$query = Workshop::select('id', 'title', 'image', 'date', 'time_start', 'time_end', 'status')->with('users:id')
->when($search !== '', function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%");
})
->when($status !== '' && $status !== 'inscrito', function ($q) use ($status) {
$q->where('status', $status);
})
->when($status === 'inscrito', function ($q) use ($user) {
$q->where('status', 'pending')
->whereHas('users', function ($q2) use ($user) {
$q2->where('users.id', $user->id);
});
})
->orWhere(function ($q) use ($user) {
$q->whereIn('status', ['realized', 'canceled'])
->whereHas('users', function ($q2) use ($user) {
->when($user->role_id !== 1, function ($q) use ($user, $status) {
if($status === 'pending'){
} else {
$q->whereHas('users', function ($q2) use ($user) {
$q2->where('users.id', $user->id);
});
};
})
->orderBy('date', 'asc')
->orderBy('time_start', 'asc')
->paginate($perPage);
$query->getCollection()->transform(function ($workshop) {
return [
'id' => $workshop->id,
'title' => $workshop->title,
'image' => $workshop->image,
'date' => $workshop->date,
'time_start' => $workshop->time_start,
'time_end' => $workshop->time_end,
'status' => $workshop->status,
'users' => $workshop->users,
];
});
});
}
$workshops = $workshopsQuery->orderBy('date', 'asc')->orderBy('time_start', 'asc')->get();
/* Os workshops são atualizados automaticamente pelo command (app->Console->Commands->UpdateWorkshopStatus.php) 'workshops:update-status' de pending para realized se for uma data passada */
if($workshops->isEmpty()) {
return response()->json([
'message' => $search !== '' ? 'Sem resultados para a pesquisa' : 'Não foram encontrados vídeos',
'data' => [],
'errors' => null,
], $search !== '' ? 200 : 404); // 200 se for pesquisa, 404 se for listagem normal
}
return response()->json([
'message' => 'Workshops obtidos com sucesso',
'data' => $query->items(),
'meta' => [
'current_page' => $query->currentPage(),
'last_page' => $query->lastPage(),
'per_page' => $query->perPage(),
'total' => $query->total(),
],
]);
}
public function search(Request $request)
{
$user = auth()->user();
$search = trim((string) $request->query('search', ''));
$workshops = Workshop::select(['id', 'title', 'image', 'date', 'time_start', 'time_end', 'status'])
->when($user->role_id !== 1, function ($query) {
$query->where('status', 'pending');
})
->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%");
})
->orderBy('date', 'asc')
->orderBy('time_start', 'asc')
->limit(20)
->get()
->map(function ($workshop) {
return [
'id' => $workshop->id,
'title' => $workshop->title,
'image' => $workshop->image,
'date' => $workshop->date,
'time_start' => $workshop->time_start,
'time_end' => $workshop->time_end,
'status' => $workshop->status,
];
});
return response()->json([
'message' => 'Resultados obtidos com sucesso',
'data' => $workshops,
]);
}
public function workshopsLength()
{
$user = auth()->user();
$userID = $user->id;
if ($user->role_id !== 1) {
$workshops = Workshop::select('id')->where('status', 'pending')->count();
$workshopsInscribed = Workshop::with('users')->where('status', 'pending')->whereHas('users', function ($query) use ($userID) {
$query->where('user_id', $userID);
})->count();
} else {
$workshops = Workshop::select('id')->where('status', 'pending')->count();
}
return response()->json([
'message' => 'Vídeos ativos obtidos com sucesso',
'data' => [
'workshops' => $workshops,
'workshopsInscribed' => $workshopsInscribed ?? 0,
],
'errors' => null,
], 200);
}
public function nextWorkshops()
{
$user = auth()->user();
if (!$user) {
return response()->json([
'message' => 'Não autenticado',
], 401);
}
$workshops = Workshop::select(['id', 'title', 'image', 'date', 'time_start', 'time_end', 'status'])
->with(['users:id'])
->where('status', 'pending')
->orderBy('date', 'asc')
->orderBy('time_start', 'asc')
->limit(3)
->get()
->map(function ($workshop) {
return [
'id' => $workshop->id,
'title' => $workshop->title,
'image' => $workshop->image,
'date' => $workshop->date,
'time_start' => $workshop->time_start,
'time_end' => $workshop->time_end,
'status' => $workshop->status,
'users' => $workshop->users->pluck('id'),
];
});
return response()->json([
'message' => 'Dashboard workshops',
'data' => $workshops,
]);
}
public function getWorkshop($id)
{
$workshop = Workshop::with('users')->find($id);
@@ -98,10 +202,9 @@ class WorkshopsController extends Controller
}
$validated = $request->validated();
try {
$imagePath = $request->file('image')->store('imageWorkshops', 'public');
try {
$workshop = Workshop::create([
'title' => $validated['title'],
'description' => $validated['description'],

View File

@@ -19,6 +19,16 @@ class CreateVideoRequest extends FormRequest
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
protected function prepareForValidation(): void
{
if ($this->has('order')) {
$this->merge([
'order' => (int) $this->order
]);
}
}
public function rules(): array
{
return [
@@ -29,6 +39,7 @@ class CreateVideoRequest extends FormRequest
'duration' => 'nullable|string|max:10',
'tags' => 'nullable|string|max:255', // nullable para não ser obrigatório
'category_ids' => 'nullable|array|exists:categories,id', // nullable caso não selecione
'order' => 'nullable|integer|min:0',
];
}
@@ -47,6 +58,8 @@ class CreateVideoRequest extends FormRequest
'thumbnail.max' => 'A thumbnail deve ter no máximo 4MB',
'tags.max' => 'As tags devem ter no máximo 50 caracteres',
'category_id.exists' => 'A categoria não existe',
'order.integer' => 'O ordem deve ser um número inteiro',
'order.min' => 'A ordem deve ser maior ou igual a 0',
];
}
}

View File

@@ -24,7 +24,7 @@ class CreateWorkshopRequest extends FormRequest
return [
'title' => 'required|string|max:255',
'description' => 'required|string|max:3000',
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:4000',
'date' => 'required|date',
'time_start' => 'required|date_format:H:i',
'time_end' => 'required|date_format:H:i',
@@ -38,7 +38,7 @@ class CreateWorkshopRequest extends FormRequest
'title.max' => 'O título deve ter no máximo 255 caracteres',
'description.max' => 'A descrição deve ter no máximo 3000 caracteres',
'image.mimes' => 'O ficheiro deve ser do formato jpg, jpeg, png ou webp',
'image.max' => 'A imagem deve ter no máximo 2MB',
'image.max' => 'A imagem deve ter no máximo 4MB',
'date.required' => 'A data é obrigatória',
'date.date' => 'A data deve ser uma data válida',
'time_start.required' => 'A hora de início é obrigatória',

View File

@@ -19,6 +19,16 @@ class UpdateVideoRequest extends FormRequest
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
protected function prepareForValidation(): void
{
if ($this->has('order')) {
$this->merge([
'order' => (int) $this->order
]);
}
}
public function rules(): array
{
return [
@@ -30,6 +40,7 @@ class UpdateVideoRequest extends FormRequest
'category_ids' => 'sometimes|array',
'category_ids.*' => 'exists:categories,id',
'is_active' => 'sometimes|boolean',
'order' => 'sometimes|integer|min:0',
];
}
@@ -42,6 +53,8 @@ class UpdateVideoRequest extends FormRequest
'thumbnail.mimes' => 'O ficheiro deve ser do formato jpg, jpeg, png ou webp',
'thumbnail.max' => 'A thumbnail deve ter no máximo 4MB',
'category_id.exists' => 'A categoria não existe',
'order.integer' => 'A ordem deve ser um número inteiro',
'order.min' => 'A ordem deve ser maior ou igual a 0',
];
}
}

View File

@@ -24,7 +24,7 @@ class UpdateWorkshopRequest extends FormRequest
return [
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|string|max:3000',
'image' => 'sometimes|image|mimes:jpg,jpeg,png,webp|max:2048',
'image' => 'sometimes|image|mimes:jpg,jpeg,png,webp|max:4000',
'date' => 'sometimes|date',
'time_start' => 'sometimes|date_format:H:i',
'time_end' => 'sometimes|date_format:H:i',
@@ -39,7 +39,7 @@ class UpdateWorkshopRequest extends FormRequest
'title.max' => 'O título deve ter no máximo 255 caracteres',
'description.max' => 'A descrição deve ter no máximo 3000 caracteres',
'image.mimes' => 'O ficheiro deve ser do formato jpg, jpeg, png ou webp',
'image.max' => 'A imagem deve ter no máximo 2MB',
'image.max' => 'A imagem deve ter no máximo 4MB',
'date.date' => 'A data deve ser uma data válida',
'time_start.date_format' => 'A hora de início deve ser uma hora válida',
'time_end.date_format' => 'A hora de término deve ser uma hora válida',

View File

@@ -10,6 +10,8 @@ class Video extends Model
{
use HasFactory;
protected $table = 'videos';
// Campos que podem ser preenchidos
protected $fillable = [
'title',
'description',
@@ -18,14 +20,68 @@ class Video extends Model
'duration',
'tags',
'is_active',
'order',
];
// Ordem dos vídeos
protected static function booted() {
static::creating(function ($video) {
if(!$video->order){
$video->order = static::max('order') + 1;
} else {
// Se definiu uma ordem já ocupada, empurra os outros
static::where('order', '>=', $video->order)
->increment('order');
}
});
static::updating(function ($video) {
if ($video->isDirty('order')) { // ← só age se a ordem foi alterada
$oldOrder = $video->getOriginal('order'); // ← ordem antiga
$newOrder = $video->order; // ← ordem nova
// Se a ordem for menor que 1, define como 1
if ($newOrder < 1) {
$video->order = 1;
$newOrder = 1;
}
if ($newOrder > $oldOrder) {
// Moveu para baixo — fecha o espaço antigo
static::where('order', '>', $oldOrder)
->where('order', '<=', $newOrder)
->decrement('order');
} else {
// Moveu para cima — abre espaço na nova posição
static::where('order', '>=', $newOrder)
->where('order', '<', $oldOrder)
->increment('order');
}
}
});
}
// Converte o campo is_active para boolean
protected $casts = [
'is_active' => 'boolean',
];
// Um vídeo pode ter várias categorias
public function categories()
{
return $this->belongsToMany(Category::class, 'video_category');
}
// Um vídeo pode ter várias visualizações
public function views()
{
return $this->hasMany(VideoView::class);
}
// Verifica se um vídeo foi assistido por um utilizador
public function isWatchedBy(User $user): bool
{
return $this->views()->where('user_id', $user->id)->exists();
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class VideoView extends Model
{
protected $fillable = ['user_id', 'video_id', 'watched_at'];
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// Telescope::night();
$this->hideSensitiveRequestDetails();
$isLocal = $this->app->environment('local');
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
return $isLocal ||
$entry->isReportableException() ||
$entry->isFailedRequest() ||
$entry->isFailedJob() ||
$entry->isScheduledTask() ||
$entry->hasMonitoredTag();
});
}
/**
* Prevent sensitive request details from being logged by Telescope.
*/
protected function hideSensitiveRequestDetails(): void
{
if ($this->app->environment('local')) {
return;
}
Telescope::hideRequestParameters(['_token']);
Telescope::hideRequestHeaders([
'cookie',
'x-csrf-token',
'x-xsrf-token',
]);
}
/**
* Register the Telescope gate.
*
* This gate determines who can access Telescope in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewTelescope', function (User $user) {
return in_array($user->email, [
//
]);
});
}
}

View File

@@ -9,6 +9,7 @@
"guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^10.10",
"laravel/sanctum": "^3.3",
"laravel/telescope": "^5.20",
"laravel/tinker": "^2.8",
"php-ffmpeg/php-ffmpeg": "^1.4",
"tymon/jwt-auth": "^2.3"

View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "39a1d2002974ed4a149001079ae3c3fc",
"content-hash": "18cd2b7643efb6f57fa98ea174f4f60c",
"packages": [
{
"name": "brick/math",
@@ -1432,6 +1432,62 @@
},
"time": "2023-12-19T18:44:48+00:00"
},
{
"name": "laravel/sentinel",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sentinel.git",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
"laravel/pint": "^1.27",
"orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^2.1.33"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sentinel\\SentinelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sentinel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mior Muhammad Zaki",
"email": "mior@laravel.com"
}
],
"support": {
"source": "https://github.com/laravel/sentinel/tree/v1.1.0"
},
"time": "2026-03-24T14:03:38+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v1.3.7",
@@ -1493,6 +1549,75 @@
},
"time": "2024-11-14T18:34:49+00:00"
},
{
"name": "laravel/telescope",
"version": "v5.20.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/telescope.git",
"reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/telescope/zipball/38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
"reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"php": "^8.0",
"symfony/console": "^5.3|^6.0|^7.0|^8.0",
"symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"ext-gd": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"laravel/octane": "^1.4|^2.0",
"orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Telescope\\TelescopeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Telescope\\": "src/",
"Laravel\\Telescope\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mohamed Said",
"email": "mohamed@laravel.com"
}
],
"description": "An elegant debug assistant for the Laravel framework.",
"keywords": [
"debugging",
"laravel",
"monitoring"
],
"support": {
"issues": "https://github.com/laravel/telescope/issues",
"source": "https://github.com/laravel/telescope/tree/v5.20.0"
},
"time": "2026-04-06T12:52:26+00:00"
},
{
"name": "laravel/tinker",
"version": "v2.11.1",

View File

@@ -168,6 +168,7 @@ return [
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\TelescopeServiceProvider::class,
])->toArray(),
/*

View File

@@ -0,0 +1,212 @@
<?php
use Laravel\Telescope\Http\Middleware\Authorize;
use Laravel\Telescope\Watchers;
return [
/*
|--------------------------------------------------------------------------
| Telescope Master Switch
|--------------------------------------------------------------------------
|
| This option may be used to disable all Telescope watchers regardless
| of their individual configuration, which simply provides a single
| and convenient way to enable or disable Telescope data storage.
|
*/
'enabled' => env('TELESCOPE_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Telescope Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Telescope will be accessible from. If the
| setting is null, Telescope will reside under the same domain as the
| application. Otherwise, this value will be used as the subdomain.
|
*/
'domain' => env('TELESCOPE_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Telescope Path
|--------------------------------------------------------------------------
|
| This is the URI path where Telescope will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('TELESCOPE_PATH', 'telescope'),
/*
|--------------------------------------------------------------------------
| Telescope Storage Driver
|--------------------------------------------------------------------------
|
| This configuration options determines the storage driver that will
| be used to store Telescope's data. In addition, you may set any
| custom options as needed by the particular driver you choose.
|
*/
'driver' => env('TELESCOPE_DRIVER', 'database'),
'storage' => [
'database' => [
'connection' => env('DB_CONNECTION', 'mysql'),
'chunk' => 1000,
],
],
/*
|--------------------------------------------------------------------------
| Telescope Queue
|--------------------------------------------------------------------------
|
| This configuration options determines the queue connection and queue
| which will be used to process ProcessPendingUpdate jobs. This can
| be changed if you would prefer to use a non-default connection.
|
*/
'queue' => [
'connection' => env('TELESCOPE_QUEUE_CONNECTION'),
'queue' => env('TELESCOPE_QUEUE'),
'delay' => env('TELESCOPE_QUEUE_DELAY', 10),
],
/*
|--------------------------------------------------------------------------
| Telescope Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will be assigned to every Telescope route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => [
'web',
Authorize::class,
],
/*
|--------------------------------------------------------------------------
| Allowed / Ignored Paths & Commands
|--------------------------------------------------------------------------
|
| The following array lists the URI paths and Artisan commands that will
| not be watched by Telescope. In addition to this list, some Laravel
| commands, like migrations and queue commands, are always ignored.
|
*/
'only_paths' => [
// 'api/*'
],
'ignore_paths' => [
'livewire*',
'nova-api*',
'pulse*',
'_boost*',
'.well-known*',
],
'ignore_commands' => [
//
],
/*
|--------------------------------------------------------------------------
| Telescope Watchers
|--------------------------------------------------------------------------
|
| The following array lists the "watchers" that will be registered with
| Telescope. The watchers gather the application's profile data when
| a request or task is executed. Feel free to customize this list.
|
*/
'watchers' => [
Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
Watchers\CacheWatcher::class => [
'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
'hidden' => [],
'ignore' => [],
],
Watchers\ClientRequestWatcher::class => [
'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
'ignore_hosts' => [],
],
Watchers\CommandWatcher::class => [
'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
'ignore' => [],
],
Watchers\DumpWatcher::class => [
'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
],
Watchers\EventWatcher::class => [
'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
'ignore' => [],
],
Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
Watchers\GateWatcher::class => [
'enabled' => env('TELESCOPE_GATE_WATCHER', true),
'ignore_abilities' => [],
'ignore_packages' => true,
'ignore_paths' => [],
],
Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
Watchers\LogWatcher::class => [
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
'level' => 'error',
],
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
Watchers\ModelWatcher::class => [
'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
'events' => ['eloquent.*'],
'hydrations' => true,
],
Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
Watchers\QueryWatcher::class => [
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
'ignore_packages' => true,
'ignore_paths' => [],
'slow' => 100,
],
Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
Watchers\RequestWatcher::class => [
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
'ignore_http_methods' => [],
'ignore_status_codes' => [],
],
Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
],
];

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return config('telescope.storage.database.connection');
}
/**
* Run the migrations.
*/
public function up(): void
{
$schema = Schema::connection($this->getConnection());
$schema->create('telescope_entries', function (Blueprint $table) {
$table->bigIncrements('sequence');
$table->uuid('uuid');
$table->uuid('batch_id');
$table->string('family_hash')->nullable();
$table->boolean('should_display_on_index')->default(true);
$table->string('type', 20);
$table->longText('content');
$table->dateTime('created_at')->nullable();
$table->unique('uuid');
$table->index('batch_id');
$table->index('family_hash');
$table->index('created_at');
$table->index(['type', 'should_display_on_index']);
});
$schema->create('telescope_entries_tags', function (Blueprint $table) {
$table->uuid('entry_uuid');
$table->string('tag');
$table->primary(['entry_uuid', 'tag']);
$table->index('tag');
$table->foreign('entry_uuid')
->references('uuid')
->on('telescope_entries')
->cascadeOnDelete();
});
$schema->create('telescope_monitoring', function (Blueprint $table) {
$table->string('tag')->primary();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$schema = Schema::connection($this->getConnection());
$schema->dropIfExists('telescope_entries_tags');
$schema->dropIfExists('telescope_entries');
$schema->dropIfExists('telescope_monitoring');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('video_views', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('video_id')->constrained()->onDelete('cascade');
$table->timestamp('watched_at')->useCurrent();
$table->unique(['user_id', 'video_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('video_views');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->integer('order')->after('tags')->default('0');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('order');
});
}
};

View File

@@ -9,6 +9,7 @@ use App\Http\Controllers\UserController;
use App\Http\Controllers\VideosController;
use App\Http\Controllers\WorkshopsController;
use App\Http\Middleware\JwtMiddleware;
use App\Http\Controllers\VideoViewController;
/*
|--------------------------------------------------------------------------
| API Routes
@@ -32,11 +33,18 @@ Route::middleware([JwtMiddleware::class])->group(function () {
Route::get('/videos', [VideosController::class, 'index']);
Route::get('/video/{id}', [VideosController::class, 'getVideo']);
Route::post('/video/{video}/watch', [VideoViewController::class, 'store']);
Route::get('/videos-length', [VideosController::class, 'videosLength']);
Route::get('/videos-search', [VideosController::class, 'search']);
Route::get('/next-videos', [VideosController::class, 'nextVideos']);
Route::get('/categories', [CategoryController::class, 'index']);
Route::get('/workshops', [WorkshopsController::class, 'index']);
Route::get('/workshop/{id}', [WorkshopsController::class, 'getWorkshop']);
Route::get('/workshops-length', [WorkshopsController::class, 'workshopsLength']);
Route::get('/workshops-search', [WorkshopsController::class, 'search']);
Route::get('/next-workshops', [WorkshopsController::class, 'nextWorkshops']);
Route::post('/categories', [CategoryController::class, 'create']);
@@ -58,7 +66,7 @@ Route::middleware([JwtMiddleware::class])->group(function () {
Route::post('/create-video', [VideosController::class, 'create']);
Route::get('/edit-video/{id}', [VideosController::class, 'getVideo']);
Route::patch('/edit-video/{id}', [VideosController::class, 'update']);
Route::delete('/video/{id}', [VideosController::class, 'destroy']);
Route::delete('/delete-video/{id}', [VideosController::class, 'destroy']);
Route::post('/create-workshop', [WorkshopsController::class, 'create']);
Route::get('/edit-workshop/{id}', [WorkshopsController::class, 'getWorkshop']);

View File

@@ -0,0 +1,2 @@
*
!.gitignore