Estado inicial: frontend React + backend Laravel

This commit is contained in:
Xavier Oliveira
2026-05-15 15:57:54 +01:00
commit 41c5f87d5b
216 changed files with 29916 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="pt-PT">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Plataforma de Tutoriais - Livetech</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap" rel="stylesheet">
<!-- Bootstrap -->
<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>
<!-- Bootstrap -->
<script type="module" src="/src/main.tsx"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous" defer></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "frontend-plataforma-tutoriais",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"bootstrap": "^5.3.8",
"plyr": "^3.8.4",
"plyr-react": "^6.0.0",
"react": "^19.2.4",
"react-bootstrap": "^2.10.10",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-router": "^7.14.0",
"sweetalert2": "^11.26.24"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,51 @@
import styles from "./styles.module.css";
export default function AccordionFAQS() {
return (
<div className="accordion" id="accordionExample">
<div className={`${styles.accordionItem} accordion-item`}>
<h2 className="accordion-header">
<button className={`${styles.accordionButton} accordion-button`} type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
<b className={`${styles.accordionNumber} me-2`}>01.</b>
<span className={styles.accordionTitle}>Pergunta 1</span>
</button>
</h2>
<div id="collapseOne" className="accordion-collapse collapse show" data-bs-parent="#accordionExample">
<div className="accordion-body">
<strong>This is the first items accordion body.</strong> It is shown by default, until the collapse plugin adds the appropriate classNamees that we use to style each element. These classNamees control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. Its also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
</div>
</div>
</div>
<div className={`${styles.accordionItem} accordion-item`}>
<h2 className="accordion-header">
<button className={`${styles.accordionButton} accordion-button collapsed`} type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
<b className={`${styles.accordionNumber} me-2`}>02.</b>
<span className={styles.accordionTitle}>Pergunta 2</span>
</button>
</h2>
<div id="collapseTwo" className="accordion-collapse collapse" data-bs-parent="#accordionExample">
<div className="accordion-body">
<strong>This is the second items accordion body.</strong> It is hidden by default, until the collapse plugin adds the appropriate classNamees that we use to style each element. These classNamees control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. Its also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
</div>
</div>
</div>
<div className={`${styles.accordionItem} accordion-item`}>
<h2 className="accordion-header">
<button className={`${styles.accordionButton} accordion-button collapsed`} type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
<b className={`${styles.accordionNumber} me-2`}>03.</b>
<span className={styles.accordionTitle}>Pergunta 3</span>
</button>
</h2>
<div id="collapseThree" className="accordion-collapse collapse" data-bs-parent="#accordionExample">
<div className="accordion-body">
<strong>This is the third items accordion body.</strong> It is hidden by default, until the collapse plugin adds the appropriate classNamees that we use to style each element. These classNamees control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. Its also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
.accordionButton{
background-color: var(--bg-white) !important;
color: var(--text-primary-color) !important;
}
.accordionItem {
border-left: none !important;
border-right: none !important;
border-top: none !important;
}
.accordionItem:last-of-type {
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
border-top: none !important;
}
.accordionNumber{
color: var(--text-primary-color);
}

View File

@@ -0,0 +1,16 @@
type UserCardProps = {
name: string;
email: string;
role: string;
};
export default function UserCard({ name, email, role }: UserCardProps) {
return (
<div className="border rounded-3 p-3">
<p className="fw-bold">Nome: {name}</p>
<p className="fw-bold">Email: {email}</p>
<p className="fw-bold">Cargo: {role}</p>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { Link } from "react-router";
import styles from "./styles.module.css";
export default function Footer() {
return (
<footer className={styles.footer}>
<Link to="/contactos">Contactos</Link>
</footer>
)
}

View File

@@ -0,0 +1,290 @@
import styles from "./styles.module.css";
import { LuUser, LuMenu, LuSearch, LuLogOut, LuUsers, LuMail, LuGraduationCap, LuTvMinimalPlay, LuLayoutDashboard, LuCircleUser, LuPanelLeftClose, LuClock3, LuCalendar } from "react-icons/lu";
import { useEffect, useState } from "react";
import { useNavigate, Link } from "react-router";
import { Button, NavLink, Offcanvas } from "react-bootstrap";
import type { User, Workshop } from "../../types";
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";
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[]>([]);
const [workshopsSearched, setWorkshopsSearched] = useState<Workshop[]>([]);
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
const isAdmin = user.role_id === 1;
const debouncedSearch = useDebounce(search, 500);
const { getVideos } = useGetVideos();
const { getWorkshops } = useGetWorkshops();
const { preloadImages } = usePreloadImages();
const navigate = useNavigate();
const handleCloseMenu = () => setShowMenu(false);
const handleShowMenu = () => setShowMenu(true);
const handleCloseSearch = () => setShowSearch(false);
const handleShowSearch = () => setShowSearch(true);
useEffect(() => {
if (debouncedSearch.trim() === "") {
setVideosSearched([]);
setWorkshopsSearched([]);
setLoading(false);
setSearchCompleted(false);
return;
}
setLoading(true);
setSearchCompleted(false);
const fetchAll = async () => {
try {
const [videosData, workshopsData] = await Promise.all([
getVideos(debouncedSearch),
getWorkshops(debouncedSearch),
]);
if (Array.isArray(videosData)) {
setVideosSearched(videosData);
} else {
setVideosSearched([]);
}
if (Array.isArray(workshopsData)) {
setWorkshopsSearched(workshopsData);
} 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>) {
event.preventDefault();
const q = search.trim();
if (q === "") {
setShowSearch(false);
return;
}
navigate(`/search?q=${encodeURIComponent(q)}`);
setShowSearch(false);
}
return (
<header className={`${styles.header} d-flex justify-content-between justify-content-xl-end align-items-center ps-4`} >
<>
<button type="button" className={`${styles.sidebarOpen} align-items-center justify-content-center d-xl-none`} onClick={handleShowMenu}>
<LuMenu size={30} />
</button>
<Offcanvas show={showMenu} onHide={handleCloseMenu} responsive="xl" className="d-xl-none">
<Offcanvas.Header >
<Offcanvas.Title className={"w-100 d-flex justify-content-between"}>
<Link className={`${styles.logoLink} justify-content-start`} to="/dashboard">
<img className={styles.logo} src="/src/assets/logo.png" alt="Logo" />
</Link>
<button type="button" className={`${styles.sidebarClose} align-items-center justify-content-center d-xl-none`} onClick={handleCloseMenu}> <LuPanelLeftClose size={24} /> </button>
</Offcanvas.Title>
</Offcanvas.Header>
<Offcanvas.Body className="d-flex flex-column">
<nav className={`${styles.nav} flex-grow-1`}>
<ul className={styles.navList}>
<li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/dashboard"> <LuLayoutDashboard className="me-2" size={24} />Dashboard</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/videos"> <LuTvMinimalPlay className="me-2" size={24} />Videos</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/workshops"> <LuGraduationCap className="me-2" size={24} />Workshops</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/admin/users"> <LuUsers className="me-2" size={24} />Utilizadores</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/contactos"> <LuMail className="me-2" size={24} />Contactos</NavLink>
</li>
</ul>
</nav>
{!isAdmin && (
<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>
</div>
)}
<div className={`${styles.logoutWrapper} text-center`}>
<Link className={styles.logoutButton} to="/logout"> <LuLogOut className="me-2" size={24} />Logout</Link>
</div>
</Offcanvas.Body>
</Offcanvas>
</>
<nav className="navbar px-3">
<div className={styles.headerRight}>
{!isAdmin && (
<>
{videos.length > 0 && (
<div className="d-none d-sm-flex align-items-center gap-1 text-muted">
<span className="fw-semibold">Vídeos assistidos</span>
<span className={`${styles.badge} badge align-content-center`}>0/{videos.filter((video) => video.is_active).length}</span>
</div>
)}
</>
)}
<div className="">
<Button className={`${styles.searchButton} me-2`} variant="link" onClick={handleShowSearch}>
<LuSearch size={30} />
</Button>
<Offcanvas show={showSearch} onHide={handleCloseSearch} placement="top" style={{ height: "fit-content" }}>
<Offcanvas.Header className={`${styles.offcanvasHeader} position-relative d-flex flex-column py-4 text-start`} closeButton>
<Offcanvas.Title className="align-self-start mb-3">Efetuar pesquisa</Offcanvas.Title>
<form onSubmit={handleSearch} className="mb-0 w-100">
<div className="input-group mb-2">
<input type="text" className="form-control border-1" placeholder="Digite para pesquisar"
value={search}
onChange={(e) => {
setSearch(e.target.value);
}} />
<button type="submit" className="btn btn-primary">Pesquisar</button>
</div>
</form>
</Offcanvas.Header>
<Offcanvas.Body className="py-0">
{loading ? (
<div className="d-flex align-items-center gap-2 my-3">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A procurar...</span>
</div>
) : (
<>
{(videosSearched.length > 0 || workshopsSearched.length > 0) && searchCompleted ? (
<>
{videosSearched.length > 0 ? (
<div className="d-flex flex-column">
<span className="fs-1 text-center fw-bold mb-1 mt-3">Vídeos</span>
<div className="row">
{videosSearched.length > 0 && (
<span className="text-muted text-start d-block">Vídeos encontrados: {videosSearched.length}</span>
)}
{videosSearched.map((video) => (
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black 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} />
<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>
</div>
</Link>
</div>
))}
</div>
</div>
) : null}
{workshopsSearched.length > 0 ? (
<div className="d-flex flex-column">
<span className="fs-1 text-center fw-bold mb-1 mt-3">Workshops</span>
<div className="row g-3 mb-3">
{workshopsSearched.length > 0 && (
<span className="text-muted text-start d-block">Workshops encontrados: {workshopsSearched.length}</span>
)}
{workshopsSearched.map((workshop) => (
<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="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div className="px-3">
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3">
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
</div>
</div>
<button type="button" className={`${styles.linkWorkshop} btn text-center mt-3 py-2 px-5 mx-auto text-decoration-none`}>
Detalhes
</button>
</Link>
</div>
</div>
))}
</div>
</div>
) : null}
</>
) : (videosSearched.length === 0 && workshopsSearched.length === 0) && searchCompleted ? (
<div className="d-flex flex-column">
<span className="text-muted text-start my-3">Sem resultados</span>
</div>
) : null}
</>
)}
</Offcanvas.Body>
</Offcanvas>
</div>
<div className="btn-group">
<button type="button" className={`${styles.headerDropdownToggle} btn dropdown-toggle`} data-bs-toggle="dropdown" aria-expanded="false">
<LuUser size={30} />
</button>
<ul className="dropdown-menu dropdown-menu-end">
<li><Link className="dropdown-item" to="/profile"><LuCircleUser className="mb-1 me-2" size={24} />A minha conta</Link></li>
<li><a className="dropdown-item mt-2" href="/logout" style={{ color: "var(--text-primary-color)" }}> <LuLogOut className="mb-1 me-2" size={24} />Sair</a></li>
</ul>
</div>
</div>
</nav>
</header >
);
}

View File

@@ -0,0 +1,295 @@
.header {
width: 100%;
background-color: var(--bg-grey);
}
.headerRight {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: flex-end;
}
.searchButton {
background-color: transparent;
color: var(--text-primary-contrast-color);
border: none;
transition: all 0.3s ease;
&:hover {
color: var(--text-primary-color);
}
}
.headerDropdownToggle {
background-color: var(--bg-white);
color: var(--text-primary-color);
border: none;
border-radius: var(--border-radius-button);
transition: all 0.3s ease;
&:hover {
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}
.headerDropdownToggle::after {
display: none;
}
.badge {
background-color: var(--bg-primary-color);
}
.sidebarOpen,
.sidebarClose {
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
border: none;
border-radius: var(--border-radius-button);
padding: 6px 12px;
font-weight: 600;
transition: all 0.3s ease;
height: fit-content;
align-self: center;
}
.logoLink {
display: inline-flex;
}
.logo {
width: 180px;
height: auto;
}
.nav {
flex-grow: 1;
}
.navList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.navItem {
margin: 0;
}
.navLink {
display: block;
width: 100%;
text-decoration: none;
color: #495057;
border-radius: 8px;
padding: 10px 12px;
transition: background-color 0.2s ease, color 0.2s ease;
&:hover {
background-color: var(--bg-grey);
color: var(--text-black);
}
}
.navLinkActive {
color: var(--primary-color);
background-color: var(--bg-primary-color-opacity);
font-weight: 600;
}
.logoutWrapper {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e9ecef;
}
.logoutButton {
display: inline-flex;
justify-content: center;
width: 200px;
text-decoration: none;
color: var(--text-white);
background-color: var(--primary-color);
border-radius: 8px;
padding: 10px 12px;
font-weight: 600;
transition: all 0.3s ease;
&:hover {
background-color: var(--secondary-color);
}
}
.thumbnail {
width: 200px;
height: 150px;
object-fit: cover;
border-radius: var(--border-radius);
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);
}
}
.buttonPlay {
display: inline-flex;
justify-content: center;
width: 150px;
text-decoration: none;
color: var(--text-white);
background-color: var(--primary-color);
border-radius: 8px;
border: none;
padding: 10px 12px;
font-weight: 600;
transition: all 0.3s ease;
&:hover {
background-color: var(--secondary-color);
}
}
.offcanvasHeader{
background-color: var(--bg-grey);
}
.offcanvasHeader :global(.btn-close) {
position: absolute;
top: 1rem;
right: 1rem;
}
.linkVideo {
display: block;
width: 100%;
border-radius: var(--border-radius);
transition: all 0.3s ease;
background-color: var(--bg-white);
}
.boxVideo {
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.boxVideo::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
}
.boxVideoInfo{
z-index: 1000;
position: relative;
}
.titleVideo {
color: var(--text-white);
font-size: var(--size-font-text);
font-weight: 700;
}
.descriptionVideo {
color: var(--text-white);
font-size: var(--size-font-small);
font-weight: 500;
}
.videoThumbnail {
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
}
.thumbnail {
width: 300px;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
}
.linkWorkshop{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-white);
color: var(--text-black);
transition: all .3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-white);
font-size: var(--size-font-small);
font-weight: 700;
border-radius: var(--border-radius-button);
padding: 5px 10px;
width: fit-content;
}
.dateWorkshopMobile, .timeWorkshopMobile{
font-size: var(--size-font-small);
font-weight: 700;
}
.boxWorkshop{
height: fit-content;
max-height: 400px;
background-color: var(--bg-grey);
border-radius: var(--border-radius);
}
.titleWorkshop{
color: var(--text-primary-color);
font-size: var(--size-font-text);
font-weight: 500;
}
.thumbnailWorkshop{
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;
}
.icon{
color: var(--text-primary-color);
}
.animateSpin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,101 @@
import styles from "./styles.module.css";
import { Link, NavLink } from "react-router";
import { useState } from "react";
import { LuPanelLeftClose, LuPanelLeftOpen, LuTvMinimalPlay } from "react-icons/lu";
import { LuGraduationCap } from "react-icons/lu";
import { LuUsers } from "react-icons/lu";
import { LuMail } from "react-icons/lu";
import { LuLayoutDashboard } from "react-icons/lu";
import { LuLogOut } from "react-icons/lu";
export default function Sidebar() {
const [sideMenu, setSideMenu] = useState(true);
const user = JSON.parse(localStorage.getItem("user") as unknown as string);
return sideMenu ? (
<aside className={styles.sidebar}>
<div className="d-flex">
<Link className={`${styles.logoLink} justify-content-center mx-auto`} to="/dashboard">
<img className={styles.logo} src="/src/assets/logo.png" alt="Logo" />
</Link>
<button type="button" className={`${styles.sidebarClose} align-items-center justify-content-center`} onClick={() => setSideMenu(false)} style={{ position: 'absolute', left: '280px', top: '8px' }}> <LuPanelLeftClose size={30} /> </button>
</div>
<nav className={`${styles.nav}`}>
{user.role_id === 1 ? (
<ul className={styles.navList}>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard"> <LuLayoutDashboard className="me-2" size={24} /> {sideMenu ? "Dashboard" : ""} </NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/videos"> <LuTvMinimalPlay className="me-2" size={24} />Videos</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/workshops"> <LuGraduationCap className="me-2" size={24} /> {sideMenu ? "Workshops" : ""} </NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/admin/users"> <LuUsers className="me-2" size={24} /> {sideMenu ? "Utilizadores" : ""} </NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/contactos"> <LuMail className="me-2" size={24} /> {sideMenu ? "Contactos" : ""} </NavLink>
</li>
</ul>
) : (
<ul className={styles.navList}>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard"> <LuLayoutDashboard className="me-2" size={24} /> {sideMenu ? "Dashboard" : ""} </NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/videos"> <LuTvMinimalPlay className="me-2" size={24} />Videos</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/workshops"> <LuGraduationCap className="me-2" size={24} /> {sideMenu ? "Workshops" : ""} </NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/contactos"> <LuMail className="me-2" size={24} /> {sideMenu ? "Contactos" : ""} </NavLink>
</li>
</ul>
)}
</nav>
<div className={styles.logoutWrapper}>
<Link className={styles.logoutButton} to="/logout"> <LuLogOut className="me-2" size={24} />Logout</Link>
</div>
</aside>
) : (
<div className={`${styles.sidebarIconsMenu}`}>
<aside >
<div className="d-flex px-2 mb-4 mt-2">
<button type="button" className={`${styles.sidebarOpen} align-items-center bg-transparent border-0 justify-content-center`} onClick={() => setSideMenu(true)}> <LuPanelLeftOpen size={30} /> </button>
</div>
<nav className={`${styles.nav} px-2`}>
<ul className={styles.navList}>
<li className={`${styles.navItem}`} title="Dashboard">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard"> <LuLayoutDashboard size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Vídeos">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/videos"> <LuTvMinimalPlay size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Workshops">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} justify-content-center ${styles.navLinkActive}` : styles.navLink} to="/workshops"> <LuGraduationCap size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Utilizadores">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive} justify-content-center` : styles.navLink} to="/admin/users"> <LuUsers size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Contactos">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/contactos"> <LuMail size={24} /></NavLink>
</li>
</ul>
</nav>
<div className="d-flex align-self-end mx-2 position-absolute bottom-0 mb-2">
<Link className={styles.logoutButton} to="/logout" title="Logout"> <LuLogOut className="" size={24} /></Link>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,103 @@
.sidebar {
width: 260px;
height: 100vh;
background: #ffffff;
border-right: 1px solid #e9ecef;
padding: 20px 16px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebarOpen, .sidebarClose {
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
border: none;
border-radius: var(--border-radius-button);
padding: 6px 12px;
font-weight: 600;
transition: all 0.3s ease;
}
@media (max-width: 1200px) {
.sidebar{
display: none;
}
.sidebarIconsMenu{
display: none;
}
.sidebarOpen, .sidebarClose {
display: none;
}
}
.logoLink {
display: inline-flex;
margin-bottom: 24px;
}
.logo {
width: 180px;
height: auto;
}
.nav {
flex: 1;
}
.navList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.navItem {
margin: 0;
}
.navLink {
display: block;
width: 100%;
text-decoration: none;
color: #495057;
border-radius: 8px;
padding: 10px 12px;
transition: background-color 0.2s ease, color 0.2s ease;
&:hover{
background-color: var(--bg-grey);
color: var(--text-black);
}
}
.navLinkActive {
color: var(--primary-color);
background-color: var(--bg-primary-color-opacity);
font-weight: 600;
}
.logoutWrapper {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e9ecef;
}
.logoutButton {
display: inline-flex;
justify-content: center;
width: 100%;
text-decoration: none;
color: var(--text-white);
background-color: var(--primary-color);
border-radius: 8px;
padding: 10px 12px;
font-weight: 600;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}

View File

@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T { /* T => indica o tipo de valor e garante que o valor que é passado é igual ao tipo que entra no hook*/
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer); // limpa se o valor mudar antes do delay
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,26 @@
import type { User } from "../types";
interface ApiUserResponse {
message: string;
data: User;
errors: null | unknown;
}
export function useGetCurrentUser() {
async function getCurrentUser(): Promise<ApiUserResponse> {
const response = await fetch("http://127.0.0.1:8000/api/me", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const currentUser = await response.json() as ApiUserResponse;
return currentUser;
}
return { getCurrentUser };
}

View File

@@ -0,0 +1,24 @@
import type { ApiErrorResponse, Video } from "../types";
export function useGetVideos() {
async function getVideos(searchQuery?: string) {
const response = await fetch(`http://127.0.0.1:8000/api/videos?search=${encodeURIComponent(searchQuery ?? "")}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
return data.data as Video[];
} else {
return (data as ApiErrorResponse);
}
}
return { getVideos };
}

View File

@@ -0,0 +1,24 @@
import type { ApiErrorResponse, Workshop } from "../types";
export function useGetWorkshops() {
async function getWorkshops(searchQuery?: string) {
const response = await fetch(`http://127.0.0.1:8000/api/workshops?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 data.data as Workshop[];
} else {
return (data as ApiErrorResponse);
}
}
return { getWorkshops };
}

View File

@@ -0,0 +1,17 @@
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,178 @@
:root {
--primary-color: #B20112;
--secondary-color: #C4534A;
--tertiary-color: #0054B0;
--success-color: #08a35d;
--neutral-color: #8A716E;
--primary-contrast-color: #410002;
--text-primary-color: var(--primary-color);
--text-secondary-color: var(--secondary-color);
--text-tertiary-color: var(--tertiary-color);
--text-neutral-color: var(--neutral-color);
--text-primary-contrast-color: var(--primary-contrast-color);
--text-white: #ffffff;
--text-black: #000000;
--size-font-title: 2.2rem;
--size-font-subtitle: 1.7rem;
--size-font-text: 1.2rem;
--size-font-small: 1rem;
--border-radius: 15px;
--border-radius-input: 5px;
--border-radius-button: 10px;
--border-primary-color: var(--primary-color);
--border-secondary-color: var(--secondary-color);
--border-tertiary-color: var(--tertiary-color);
--border-neutral-color: var(--neutral-color);
--bg-white: #ffffff;
--bg-grey: #F3F3F3;
--bg-primary-color: var(--primary-color);
--bg-primary-color-opacity: #FFEDEA;
--bg-secondary-color: var(--secondary-color);
--bg-tertiary-color: var(--tertiary-color);
--bg-tertiary-color-opacity: #CFE2FF;
--bg-success-color-opacity: #D1E7DD;
--bg-neutral-color: var(--neutral-color);
--bg-gradient: linear-gradient(135deg, #b20112 0%, #8e010e 100%);
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--shadow-primary:0 4px 10px rgba(178, 1, 18, 0.4), 0 2px 6px rgba(142, 1, 14, 0.3);;
--font-family: 'Manrope', sans-serif;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: var(--bg-white);
}
*{
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
font-family: var(--font-family);
box-sizing: border-box;
margin: 0;
padding: 0;
}
h1,
h2 {
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
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;
}
code,
.counter {
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
/* Pagination */
html body .pagination .page-item.active .page-link {
background-color: var(--bg-primary-color-opacity) !important;
border-color: var(--border-primary-color) !important;
color: var(--text-primary-color) !important;
}
.page-link{
color: var(--text-neutral-color) !important;
}
input:focus,
textarea:focus,
select:focus,
button:focus {
outline: none !important;
box-shadow: none !important;
border: none;
}

View File

@@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import {router} from './routes'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)

View File

@@ -0,0 +1,160 @@
import { useState } from "react";
import { Link } from "react-router";
import type { ApiErrorResponse, User } from "../../../types";
import type { CreateUserResponse } from "../../../types";
import styles from "./styles.module.css";
import { LuArrowLeft } from "react-icons/lu";
import { LuPlus } from "react-icons/lu";
export default function CreateUser() {
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [password_confirmation, setPasswordConfirmation] = useState<string>("");
const [role_id, setRoleId] = useState("2");
const [createUserResponse, setCreateUserResponse] = useState<CreateUserResponse | null>(null);
const [error, setError] = useState<ApiErrorResponse | null>(null);
async function create() {
//validação dos campos
if (name.length < 3) {
setError({
message: "Nome deve ter pelo menos 3 caracteres",
data: null,
errors: {}
});
return;
}
if (!email.includes('@')) {
setError({
message: "Email inválido",
data: null,
errors: {}
});
return;
}
if (password !== password_confirmation) {
setError({
message: "As senhas não coincidem",
data: null,
errors: {}
});
return;
}
if (password.length < 6) {
setError({
message: "Password deve ter pelo menos 6 caracteres",
data: null,
errors: {}
});
return;
}
if (role_id === "") {
setError({
message: "Cargo é obrigatório",
data: null,
errors: {}
});
return;
}
const payload = {
name: name,
email: email,
password: password,
password_confirmation: password_confirmation,
role_id: role_id,
}
const response = await fetch("http://127.0.0.1:8000/api/users", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify(
payload
),
});
const data = await response.json();
if (response.ok){
setCreateUserResponse({
data: data.data as User,
message: data.message,
});
setName("");
setEmail("");
setPassword("");
setPasswordConfirmation("");
setRoleId("2");
} else {
setError({
message: data.message,
data: null,
errors: data.errors
});
}
}
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="/users"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Utilizadores</span> </Link></button>
</div>
<h1 className={styles.title}>Criar Utilizador</h1>
<div>
<div>
<div className="row g-3">
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label>
<input type="text" className="form-control py-2" id="name" name="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="email">Email</label>
<input type="email" className="form-control py-2" id="email" name="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="password">Password</label>
<input type="password" className="form-control py-2" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="password_confirmation">Confirme a Password</label>
<input type="password" className="form-control py-2" id="password_confirmation" name="password_confirmation" value={password_confirmation} onChange={(e) => setPasswordConfirmation(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="role_id">Cargo</label>
<select className="form-select py-2" id="role_id" name="role_id" value={role_id} onChange={(e) => setRoleId(e.target.value)}>
<option value="2">Utilizador</option>
<option value="1">Administrador</option>
</select>
</div>
</div>
{createUserResponse?.message ? (
<div className="alert alert-success mt-4">
{createUserResponse.message}
</div>
) : error?.errors ? (
<div className="alert alert-danger mt-4">
<p>{error.message}</p>
</div>
): null}
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="submit" className={`${styles.createButton}`} onClick={create}><LuPlus className="mb-1 text-white" /> Adicionar</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.createButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}

View File

@@ -0,0 +1,280 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import type { ApiErrorResponse, CreateCategoryResponse } from "../../../types";
import type { CreateVideoResponse } from "../../../types";
import type { Category } from "../../../types";
import styles from "./styles.module.css";
import { LuArrowLeft, LuPlus } from "react-icons/lu";
import { LuUpload } from "react-icons/lu";
import Swal from "sweetalert2";
export default function CreateVideo() {
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [videoFile, setVideoFile] = useState<File | null>(null);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [tags, setTags] = useState<string>("");
const [createVideoResponse, setCreateVideoResponse] = useState<CreateVideoResponse | null>(null);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [category_ids, setCategoryIds] = useState<string[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [createCategoryResponse, setCreateCategoryResponse] = useState<CreateCategoryResponse | null>(null);
useEffect(() => {
getCategories();
}, []);
async function getCategories() {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setCategories(data.data);
setError(null);
} else {
setCategories([]);
setError(data as ApiErrorResponse);
}
}
async function createVideo() {
setCreateVideoResponse(null);
setError(null);
if (!videoFile || !thumbnailFile) {
setError({
message: "Vídeo e thumbnail são obrigatórios.",
data: null,
errors: {},
});
return;
}
const formData = new FormData();
formData.append("title", title);
formData.append("description", description);
formData.append("url", videoFile);
formData.append("thumbnail", thumbnailFile);
formData.append("tags", tags);
category_ids.forEach(id => {
formData.append("category_ids[]", id);
});
setIsUploading(true);
setUploadProgress(0);
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:8000/api/create-video");
xhr.setRequestHeader("Authorization", `Bearer ${localStorage.getItem("token")}`);
xhr.setRequestHeader("Accept", "application/json");
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setUploadProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
setIsUploading(false);
let data: any = null;
try {
data = xhr.responseText ? JSON.parse(xhr.responseText) : null;
} catch {
setError({ message: "Resposta inválida do servidor.", data: null, errors: {} });
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
setCreateVideoResponse({ message: "Vídeo criado com sucesso.", data: data.data });
setError(null);
setUploadProgress(100);
setIsUploading(false);
setTitle("");
setDescription("");
setVideoFile(null);
setThumbnailFile(null);
setTags("");
setCategoryIds([]);
} else {
setCreateVideoResponse(null);
setIsUploading(false);
setError(
data ?? { message: `Erro ${xhr.status} no upload.`, data: null, errors: {} }
);
}
};
xhr.onerror = () => {
setIsUploading(false);
setError({ message: "Falha de rede durante upload.", data: null, errors: {} });
};
xhr.send(formData);
}
function handleCreateCategory() {
Swal.fire({
title: 'Criar nova categoria',
input: 'text',
inputPlaceholder: 'Categoria...',
showCancelButton: true,
confirmButtonText: 'Adicionar',
cancelButtonText: 'Cancelar',
confirmButtonColor: 'var(--primary-color)',
inputValidator: (value) => {
if (!value) {
return 'Indique o nome da categoria!';
}
}
}).then((result) => {
if (result.isConfirmed) {
const categoryName = result.value;
createCategory(categoryName);
}
});
}
async function createCategory(categoryName: string ) {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify({
name: categoryName
})
});
const data = await response.json();
setCreateCategoryResponse(data);
setError(data as ApiErrorResponse);
if (response.ok) {
setCreateCategoryResponse(data);
getCategories();
setError(null);
} else {
setCreateCategoryResponse(null);
setError(data as ApiErrorResponse);
}
}
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value, checked } = e.target;
setCategoryIds(prev =>
checked ? [...prev, value] : prev.filter(id => id !== value)
);
};
return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<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>
<h1 className={styles.title}>Adicionar Vídeo</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>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" placeholder="Insira o título do vídeo" 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>
<textarea rows={5} className="form-control py-2" id="description" name="description" placeholder="Insira uma descrição para o vídeo" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="col-12 px-1 text-start">
<label className="form-label fw-bold" htmlFor="tags">Palavras-chave <small className="text-muted">(separar por vírgulas)</small></label>
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" placeholder="Insira as palavras-chave do vídeo" value={tags} onChange={(e) => setTags(e.target.value)} />
</div>
<div className="col-12 col-sm-6 px-1 text-start">
<label className="form-label fw-bold" htmlFor="url">Adicionar vídeo</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" /> {videoFile ? videoFile.name : "Carregar vídeo"} </span>
<input type="file" className="form-control py-2 text-truncate" id="url" name="url" accept="video/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setVideoFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 col-sm-6 px-1 text-start">
<label className="form-label fw-bold" htmlFor="thumbnail">Adicionar 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 imagem"} </span>
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 px-1 text-start">
<div className="d-flex">
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
</div>
<div>
{categories && categories.length > 0 ? (
categories.map((category) => (
<div key={category.id}>
<input type="checkbox" name="category_id" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox}/>
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
</div>))
) : (
<div>
<span>Nenhuma categoria encontrada</span>
</div>
)}
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
</div>
</div>
</div>
{error?.message ? (
<div className="alert alert-danger mt-4">
<p className="mb-1">{error.message}</p>
{Object.keys(error.errors ?? {}).length > 0 ? (
<ul className="mb-0 small">
{Object.entries(error.errors).flatMap(([field, msgs]) =>
msgs.map((m) => (
<li key={`${field}-${m}`}>
<strong>{field}</strong>: {m}
</li>
))
)}
</ul>
) : null}
</div>
) : createVideoResponse?.message || createCategoryResponse?.message ? (
<div className="alert alert-success mt-4">
{createVideoResponse?.message || createCategoryResponse?.message}
</div>
) : null}
{isUploading && (
<div className="mt-3">
<div className="progress" role="progressbar" aria-valuenow={uploadProgress} aria-valuemin={0} aria-valuemax={100}>
<div
className="progress-bar"
style={{ width: `${uploadProgress}%` }}
>
{uploadProgress}%
</div>
</div>
<small className="text-muted">A carregar vídeo...</small>
</div>
)}
<button type="button" className={`${styles.btnAdicionarVideo} btn btn-primary mt-5`} onClick={createVideo} disabled={isUploading}>{isUploading ? "A carregar..." : "Adicionar vídeo"}</button>
</div>
)
}

View File

@@ -0,0 +1,78 @@
.container{
width: 100%;
max-width: 1000px;
align-self: start;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.btnAdicionarVideo{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.btnAdicionarCategoria{
background-color: transparent;
color: var(--primary-color);
border: none;
font-size: var(--size-font-subtitle);
padding: 0;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
color: var(--neutral-color);
}
}
.inputFiles{
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529 !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,224 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router";
import type { ApiErrorResponse, Workshop } from "../../../types";
import type { CreateWorkshopResponse } from "../../../types";
import styles from "./styles.module.css";
import { LuArrowLeft, LuPlus, LuUpload } from "react-icons/lu";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { pt } from "date-fns/locale";
import { CgSpinner } from "react-icons/cg";
export default function CreateWorkshop() {
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [image, setImage] = useState<File | null>(null);
const [date, setDate] = useState<Date | null>(null);
const [time_start, setTimeStart] = useState<Date | null>(null);
const [time_end, setTimeEnd] = useState<Date | null>(null);
const [createWorkshopResponse, setCreateWorkshopResponse] = useState<CreateWorkshopResponse | null>(null);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
/* Verificação do role_id no frontend das páginas do admin */
const [checkingRole, setCheckingRole] = useState<boolean>(true);
useEffect(() => {
getVerifyRole();
}, []);
async function getVerifyRole() {
try {
const response = await fetch("http://127.0.0.1:8000/api/me", {
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
setCheckingRole(false);
navigate("/dashboard");
return;
}
const data = await response.json();
console.log("role_id:", data?.data?.role_id);
if (data?.data?.role_id !== 1) {
setError({
message: "Acesso negado. Apenas administradores podem aceder a esta página",
data: null,
errors: {},
});
navigate("/dashboard");
return;
}
} catch (error) {
setCheckingRole(false);
navigate("/dashboard");
return;
}
}
//função para formatar a data para o formato Local
function formatDateLocalISO(d: Date) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
async function createWorkshop() {
setCreateWorkshopResponse(null);
setError(null);
if (!title || !description || !image || !date || !time_start || !time_end) {
setError({
message: "Todos os campos são obrigatórios",
data: null,
errors: {},
});
return;
}
const formData = new FormData();
formData.append("title", title);
formData.append("description", description);
formData.append("image", image as File);
formData.append("date", formatDateLocalISO(date));
formData.append("time_start", time_start.toTimeString().slice(0, 5));
formData.append("time_end", time_end.toTimeString().slice(0, 5));
const response = await fetch("http://127.0.0.1:8000/api/create-workshop", {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
});
setIsLoading(true);
const data = await response.json();
if (response.ok) {
setCreateWorkshopResponse({ message: "Workshop criado com sucesso.", data: data.data as Workshop });
setError(null);
setTitle("");
setDescription("");
setImage(null);
setDate(null);
setTimeStart(null);
setTimeEnd(null);
setIsLoading(false);
} else {
setCreateWorkshopResponse(null);
setError(data as ApiErrorResponse);
}
}
if (checkingRole) {
return (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/workshops"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Workshops</span> </Link></button>
</div>
<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>
<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>
<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>
<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>
<DatePicker
selected={date}
onChange={(d: Date | null) => setDate(d)}
dateFormat="dd/MM/yyyy"
locale={pt}
className="form-control py-2"
placeholderText="Seleciona a data"
id="date"
/>
</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>
<DatePicker
selected={time_start}
onChange={(t: Date | null) => setTimeStart(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de início"
id="time_start"
minTime={time_end
? new Date(new Date(time_end).getTime() - 30 * 60 * 1000)
: new Date(new Date().setHours(9, 0, 0, 0))
}
maxTime={new Date(new Date().setHours(23, 59, 59, 999))}
/>
</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>
<DatePicker
selected={time_end}
onChange={(t: Date | null) => setTimeEnd(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de término"
id="time_end"
minTime={time_start
? new Date(new Date(time_start).getTime() + 30 * 60 * 1000)
: new Date(new Date().setHours(23, 59, 59, 999))
}
maxTime={new Date(new Date().setHours(23, 59, 59, 999))}
/>
</div>
</div>
{createWorkshopResponse?.message ? (
<div className="alert alert-success mt-4">
{createWorkshopResponse.message}
</div>
) : error?.message ? (
<div className="alert alert-danger mt-4">
<p>{error.message}</p>
</div>
) : null}
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="submit" onClick={createWorkshop} className={`${styles.btnAdicionarWorkshop}`} disabled={isLoading}><LuPlus className="mb-1 text-white" /> Adicionar Workshop</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
.container{
width: 100%;
max-width: 1000px;
align-self: start;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.inputFiles{
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529 !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.btnAdicionarWorkshop{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,426 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import type { ApiErrorResponse, Category, Video } from "../../../types";
import styles from "./styles.module.css";
import { LuTrash2 } from "react-icons/lu";
import { LuPencil } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import { LuArrowLeft } from "react-icons/lu";
import Swal from "sweetalert2";
import { Plyr } from "plyr-react";
import "plyr-react/plyr.css";
import { LuUpload } from "react-icons/lu";
import { LuPlus } from "react-icons/lu";
import { LuX } from "react-icons/lu";
import { LuCheck } from "react-icons/lu";
export default function editVideo() {
const { id } = useParams();
const [video, setVideo] = useState<Video | null>(null);
const [messageDeleteVideo, setMessageDeleteVideo] = useState<string>("");
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [messageUpdateVideo, setMessageUpdateVideo] = useState<string>("");
const [loading, setLoading] = useState(true);
const [categories, setCategories] = useState<Category[]>([]);
const [category_ids, setCategoryIds] = useState<string[]>([]);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [formEdit, setFormEdit] = useState<boolean>(false);
const [isActive, setIsActive] = useState<boolean>();
useEffect(() => {
getVideo();
}, [id]);
useEffect(() => {
if (!video) return;
setCategoryIds(video.categories?.map((category) => category.id.toString()) ?? []);
setIsActive(Boolean(video.is_active));
}, [video]);
async function getVideo() {
setLoading(true);
try {
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setVideo(data.data);
} else {
setVideo(null);
setError(data as ApiErrorResponse);
}
} catch {
console.error(error);
} finally {
setLoading(false);
}
}
async function destroy(id: number) {
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setMessageDeleteVideo(data.message as string);
setError(null);
} else {
setMessageDeleteVideo("");
setError(data as ApiErrorResponse);
}
}
function handleDeleteVideo() {
Swal.fire({
text: 'Tem a certeza que deseja apagar este vídeo?',
showCancelButton: true,
confirmButtonText: 'Sim, apagar',
confirmButtonColor: 'var(--primary-color)',
cancelButtonText: 'Cancelar',
}).then((result) => {
if (result.isConfirmed) {
destroy(video?.id as number);
}
});
}
async function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
// Garante categorias atualizadas via state (checkboxes controladas)
formData.delete("category_ids[]");
category_ids.forEach((categoryId) => {
formData.append("category_ids[]", categoryId);
});
// Mantém compatibilidade de upload de ficheiro com Laravel
// quando a rota aceita PATCH (method spoofing)
formData.append("_method", "PATCH");
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: formData,
});
const data = await response.json();
if (response.ok) {
setVideo(data.data);
setMessageUpdateVideo(data.message as string);
setTimeout(() => {
setMessageUpdateVideo("");
}, 3000);
setError(null);
getVideo();
setFormEdit(false);
} else {
setMessageUpdateVideo("");
setError(data as ApiErrorResponse);
}
}
useEffect(() => {
getCategories();
}, []);
async function getCategories() {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setCategories(data.data);
setError(null);
} else {
setCategories([]);
setError(data as ApiErrorResponse);
}
}
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value, checked } = e.target;
setCategoryIds(prev =>
checked ? [...prev, value] : prev.filter(id => id !== value)
);
};
function handleCreateCategory(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
Swal.fire({
title: 'Criar nova categoria',
input: 'text',
inputPlaceholder: 'Categoria...',
showCancelButton: true,
confirmButtonText: 'Adicionar',
cancelButtonText: 'Cancelar',
confirmButtonColor: 'var(--primary-color)',
inputValidator: (value) => {
if (!value) {
return 'Indique o nome da categoria!';
}
}
}).then((result) => {
if (result.isConfirmed) {
const categoryName = result.value;
createCategory(categoryName);
}
});
}
async function createCategory(categoryName: string) {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify({
name: categoryName
})
});
const data = await response.json();
setError(data as ApiErrorResponse);
if (response.ok) {
getCategories();
setError(null);
} else {
setError(data as ApiErrorResponse);
}
}
if (loading) {
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
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>
{messageDeleteVideo ? (
<div className="">
<div className="alert alert-success mt-4">
{messageDeleteVideo}
</div>
</div>
) : video ? (
<div className="my-3">
<span className={styles.title}>{video.title} </span>
<div className={`${styles.videoContainer} mt-4`} style={{ maxWidth: 1000, margin: "0 auto" }}>
<Plyr
source={{
type: "video",
sources: [
{
src: `http://127.0.0.1:8000${video.url}`,
type: "video/mp4",
},
],
}}
/>
</div>
{formEdit ? (
<div className="mt-5">
<span className={styles.title}>Alterar detalhes do vídeo</span>
<div className="row mx-auto g-4">
<form method="patch" onSubmit={update}>
<input type="hidden" name="video_id" value={video?.id} />
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="title">Título</label>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" defaultValue={video?.title} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
<textarea rows={5} className="form-control py-2" id="description" name="description" defaultValue={video?.description} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="tags">Tags <small className="text-muted">(separar por vírgulas)</small></label>
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" defaultValue={video?.tags} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="thumbnail">Atualizar thumbnail</label>
<div className="position-relative">
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1 me-2" /> {thumbnailFile ? thumbnailFile.name : "Carregar imagem"} </span>
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 px-1 text-start mb-3">
<div className="d-flex">
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
</div>
<div>
{categories && categories.length > 0 ? (
categories.map((category) => (
<div key={category.id}>
<input type="checkbox" name="category_ids[]" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox} />
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
</div>))
) : (
<div>
<span>Nenhuma categoria encontrada</span>
</div>
)}
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
</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>
<div className="d-flex gap-4">
<span>
<input
type="radio"
name="is_active"
id="is_active_true"
value="1"
checked={isActive === true}
onChange={() => setIsActive(true)}
/>
<label className="ms-1" htmlFor="is_active_true">Ativo</label>
</span>
<span>
<input
type="radio"
name="is_active"
id="is_active_false"
value="0"
checked={isActive === false}
onChange={() => setIsActive(false)}
/>
<label className="ms-1" htmlFor="is_active_false">Inativo</label>
</span>
</div>
{/* <select className="form-control py-2 text-truncate" id="is_active" name="is_active" defaultValue={video?.is_active ? "Ativo" : "Inativo"} onChange={(e) => setIsActive(e.target.value)}>
<option value="1">Ativo</option>
<option value="0">Inativo</option>
</select> */}
</div>
{error?.errors ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{error.message}</p>
</div>
</div>
) : null}
<div className="buttonsContainer mt-5 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.submitButton} bg-primary`}>Submeter Dados <LuCheck className="mb-1 text-white" /></button>
</div>
</form>
</div>
</div>
) : (
<div>
{messageUpdateVideo ? (
<div className="">
<div className="alert alert-success mt-4">
{messageUpdateVideo}
</div>
</div>
) : null}
{error?.errors ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{error.message}</p>
</div>
</div>) : null}
{video?.is_active ? (
<div className="text-start mt-3 d-flex flex-column">
<b>Estado: </b>
<span className="bg-success text-white text-center p-2" style={{ width: '100px' }}> <b>Ativo</b></span>
</div>
) : (
<div className="text-start mt-3 d-flex flex-column">
<b>Estado: </b>
<span className="bg-danger text-white text-center p-2" style={{ width: '100px' }}> <b>Inativo</b></span>
</div>
)}
<div className="text-start mt-3">
<b>Descrição: </b>
<p>{video.description}</p>
</div>
<div className="text-start mt-3">
<b>Categorias: </b>
<p>{video.categories?.length ? video.categories.map((category) => category.name).join(", ") : "Sem categorias"}</p>
</div>
<div className="text-start mt-3">
<b>Tags: </b>
<p>{video.tags}</p>
</div>
<div className="text-start mt-3 d-flex flex-column">
<b>Thumbnail: </b>
<img src={`http://127.0.0.1:8000${video.thumbnail}`} alt={video.title} className={`${styles.imgThumbnail} img-fluid img-thumbnail mt-2`} />
</div>
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center" >
<button type="button" className={`${styles.updateButton} bg-primary`} onClick={() => setFormEdit(true)}> {formEdit ? "Cancelar" : "Editar"} <LuPencil className="mb-1 text-white" /></button>
<button type="button" className={`${styles.deleteButton}`} onClick={() => handleDeleteVideo()}>Apagar <LuTrash2 className="mb-1 text-white" /></button>
</div>
</div>
)}
</div>
) : error?.errors ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{error.message}</p>
</div>
</div>
) : (
<div className="text-center alert alert-danger mt-5 align-items-center">
<span>Vídeo não encontrado</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,224 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router";
import type { ApiErrorResponse, Workshop } from "../../../types";
import type { CreateWorkshopResponse } from "../../../types";
import styles from "./styles.module.css";
import { LuArrowLeft, LuPlus, LuUpload } from "react-icons/lu";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { pt } from "date-fns/locale";
import { CgSpinner } from "react-icons/cg";
export default function CreateWorkshop() {
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [image, setImage] = useState<File | null>(null);
const [date, setDate] = useState<Date | null>(null);
const [time_start, setTimeStart] = useState<Date | null>(null);
const [time_end, setTimeEnd] = useState<Date | null>(null);
const [createWorkshopResponse, setCreateWorkshopResponse] = useState<CreateWorkshopResponse | null>(null);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
/* Verificação do role_id no frontend das páginas do admin */
const [checkingRole, setCheckingRole] = useState<boolean>(true);
useEffect(() => {
getVerifyRole();
}, []);
async function getVerifyRole() {
try {
const response = await fetch("http://127.0.0.1:8000/api/me", {
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
setCheckingRole(false);
navigate("/dashboard");
return;
}
const data = await response.json();
console.log("role_id:", data?.data?.role_id);
if (data?.data?.role_id !== 1) {
setError({
message: "Acesso negado. Apenas administradores podem aceder a esta página",
data: null,
errors: {},
});
navigate("/dashboard");
return;
}
} catch (error) {
setCheckingRole(false);
navigate("/dashboard");
return;
}
}
//função para formatar a data para o formato Local
function formatDateLocalISO(d: Date) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
async function createWorkshop() {
setCreateWorkshopResponse(null);
setError(null);
if (!title || !description || !image || !date || !time_start || !time_end) {
setError({
message: "Todos os campos são obrigatórios",
data: null,
errors: {},
});
return;
}
const formData = new FormData();
formData.append("title", title);
formData.append("description", description);
formData.append("image", image as File);
formData.append("date", formatDateLocalISO(date));
formData.append("time_start", time_start.toTimeString().slice(0, 5));
formData.append("time_end", time_end.toTimeString().slice(0, 5));
const response = await fetch("http://127.0.0.1:8000/api/create-workshop", {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
});
setIsLoading(true);
const data = await response.json();
if (response.ok) {
setCreateWorkshopResponse({ message: "Workshop criado com sucesso.", data: data.data as Workshop });
setError(null);
setTitle("");
setDescription("");
setImage(null);
setDate(null);
setTimeStart(null);
setTimeEnd(null);
setIsLoading(false);
} else {
setCreateWorkshopResponse(null);
setError(data as ApiErrorResponse);
}
}
if (checkingRole) {
return (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/workshops"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Workshops</span> </Link></button>
</div>
<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>
<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>
<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>
<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>
<DatePicker
selected={date}
onChange={(d: Date | null) => setDate(d)}
dateFormat="dd/MM/yyyy"
locale={pt}
className="form-control py-2"
placeholderText="Seleciona a data"
id="date"
/>
</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>
<DatePicker
selected={time_start}
onChange={(t: Date | null) => setTimeStart(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de início"
id="time_start"
minTime={time_end
? new Date(new Date(time_end).getTime() - 30 * 60 * 1000)
: new Date(new Date().setHours(9, 0, 0, 0))
}
maxTime={new Date(new Date().setHours(23, 59, 59, 999))}
/>
</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>
<DatePicker
selected={time_end}
onChange={(t: Date | null) => setTimeEnd(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de término"
id="time_end"
minTime={time_start
? new Date(new Date(time_start).getTime() + 30 * 60 * 1000)
: new Date(new Date().setHours(23, 59, 59, 999))
}
maxTime={new Date(new Date().setHours(23, 59, 59, 999))}
/>
</div>
</div>
{createWorkshopResponse?.message ? (
<div className="alert alert-success mt-4">
{createWorkshopResponse.message}
</div>
) : error?.message ? (
<div className="alert alert-danger mt-4">
<p>{error.message}</p>
</div>
) : null}
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="submit" onClick={createWorkshop} className={`${styles.btnAdicionarWorkshop}`} disabled={isLoading}><LuPlus className="mb-1 text-white" /> Adicionar Workshop</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
.container{
width: 100%;
max-width: 1000px;
align-self: start;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.inputFiles{
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529 !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.btnAdicionarWorkshop{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,104 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.statusAlert{
width: 100px;
border-radius: var(--border-radius-input);
}
.imgThumbnail{
max-width: 400px;
border-radius: var(--border-radius);
}
.inputFiles{
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529 !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.btnAdicionarCategoria{
background-color: transparent;
color: var(--primary-color);
border: none;
font-size: var(--size-font-subtitle);
padding: 0;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
color: var(--neutral-color);
}
}
.updateButton, .submitButton{
background-color: var(--text-primary);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.deleteButton, .cancelButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,297 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import type { ApiErrorResponse, User, Video } from "../../../types";
import styles from "./styles.module.css";
import { LuCheck, LuTrash2, LuX, LuPencil, LuArrowLeft } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2";
export default function User() {
const { id } = useParams();
const [user, setUser] = useState<User | null>(null);
const [messageGetUser, setMessageGetUser] = useState<string>("");
const [messageDeleteUser, setMessageDeleteUser] = useState<string>("");
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [messageUpdateUser, setMessageUpdateUser] = useState<string>("");
const [loading, setLoading] = useState(true);
const [formEdit, setFormEdit] = useState<boolean>(false);
useEffect(() => {
getUser();
}, [id]);
async function getUser() {
setLoading(true);
if (id === user?.id) {
setError({
message: "Não pode editar o seu próprio utilizador",
data: null,
errors: {},
});
return;
}
try {
const response = await fetch(`http://127.0.0.1:8000/api/user/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setUser(data.data);
setMessageGetUser("");
} else {
setUser(null);
setError(data as ApiErrorResponse);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
async function destroy(id: number) {
const response = await fetch(`http://127.0.0.1:8000/api/user/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setMessageDeleteUser(data.message as string);
setError(null);
} else {
setMessageDeleteUser("");
setError(data as ApiErrorResponse);
}
}
function handleDeleteUser() {
Swal.fire({
text: 'Tem a certeza que deseja apagar este utilizador?',
showCancelButton: true,
confirmButtonText: 'Sim, apagar',
confirmButtonColor: 'var(--primary-color)',
cancelButtonText: 'Cancelar',
}).then((result) => {
if (result.isConfirmed) {
destroy(user?.id as number);
}
});
}
async function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const user_id = formData.get("user_id");
const name = formData.get("name");
const email = formData.get("email");
const role_id = formData.get("role_id");
const payload = {
user_id: user_id,
name: name,
email: email,
role_id: role_id,
};
const response = await fetch(`http://127.0.0.1:8000/api/user/${id}`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok) {
setUser(data.data);
setFormEdit(false);
setMessageUpdateUser(data.message as string);
setTimeout(() => {
setMessageUpdateUser("");
}, 3000)
setError(null);
getUser();
} else {
setMessageUpdateUser("");
setError(data as ApiErrorResponse);
}
}
if (loading) {
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
if (error) {
return (
<div className="text-center mt-5">
<p>{(error as ApiErrorResponse).message}</p>
</div>
)
}
return (
<div className={`${styles.container} p-1 p-xl-0`}>
<div className={"text-start mt-5"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/admin/users"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Utilizadores</span> </Link></button>
</div>
{messageDeleteUser ? (
<div className="">
<div className="alert alert-success mt-4">
{messageDeleteUser}
</div>
</div>
) : user ? (
<div className="my-3">
<span className={styles.title}>Dados do Utilizador </span>
{messageUpdateUser ? (
<div className="">
<div className="alert alert-success mt-4">
{messageUpdateUser}
</div>
</div>
) : null}
<div className="row mt-5 justify-content-between">
<div className="col-12 col-lg-8 p-2 rounded-3">
<div className={`${styles.userCard} d-flex flex-column flex-md-row g-5 p-4 bg-white h-100`}>
<div className="col-12 col-md-6 d-flex flex-column text-start flex-column mt-xl-0">
<span className={`badge text-uppercase bg-primary fw-bold px-4 py-3 fs-6 mb-3 d-flex d-md-none align-self-center ${user.role_id === 1 ? 'bg-primary bg-gradient text-white' : 'bg-secondary bg-gradient text-white'} `} style={{ width: "fit-content" }}>{user.role_id === 1 ? "Administrador" : "Utilizador"}</span>
<span className="fw-bold fs-4">{user.name}</span>
<span className="fs-5 flex-grow-1">{user.email}</span>
<div className="d-flex flex-column">
<span className="d-flex justify-content-start text-black text-muted flex-wrap flex-sm-nowrap fs-6 fw-medium mt-4 py-0" style={{ maxWidth: "275px" }}>Membro desde: <b className="fw-bold fs-6">{new Date(user.created_at).toLocaleDateString('pt-PT')}</b></span>
<span className="d-flex justify-content-start text-black text-muted flex-wrap flex-sm-nowrap fs-6 fw-medium py-0" style={{ maxWidth: "275px" }}>Última actualização: <b className="fw-bold fs-6">{new Date(user.updated_at).toLocaleDateString('pt-PT')}</b></span>
</div>
</div>
<div className="col-12 col-md-6 d-flex flex-column gap-2 text-end align-items-start align-items-md-end mt-0 justify-content-between mx-auto mx-md-0">
<span className={`badge text-uppercase d-none d-md-flex fw-bold px-4 py-3 fs-6 mt-0 ${user.role_id === 1 ? 'bg-primary bg-gradient text-white' : 'bg-secondary bg-gradient text-white'}`} style={{ width: "fit-content" }}>{user.role_id === 1 ? "Administrador" : "Utilizador"}</span>
<div className="buttonsContainer mt-4 mt-md-5 d-flex gap-2 justify-content-center mx-auto mx-md-0 flex-wrap" >
<a className={`${styles.updateButton} text-decoration-none text-primary`} href="#formEditUser" onClick={() => { setFormEdit(true); setMessageUpdateUser(""); }}> Editar <LuPencil className={`${styles.icon} mb-1 text-primary`} /></a>
<a className={`${styles.deleteButton} text-decoration-none text-danger`} onClick={() => handleDeleteUser()}>Apagar <LuTrash2 className={`${styles.icon} mb-1 text-danger`} /></a>
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-4 p-2">
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-primary-color-opacity)" }}>Vídeos assistidos</span>
<span className="fs-2 fw-bold text-white">0/{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-6" style={{ color: "var(--bg-grey)" }}>Workshops</span>
<span className="fs-2 fw-bold text-white">0</span>
</div>
</div>
</div>
</div>
{/* <div className="row g-5 mt-4 px-5 px-xl-0">
<div className="col-12 col-sm-6 col-md-4 col-xl-1 d-flex flex-column text-start flex-column gap-2 mt-3 mt-xl-0">
<b>ID</b>
<span>{user.id}</span>
</div>
<div className="col-12 col-sm-6 col-md-4 col-xl d-flex flex-column text-start flex-column gap-2 mt-3 mt-xl-0 px-xl-1">
<b>Nome</b>
<span>{user.name}</span>
</div>
<div className="col-12 col-sm-6 col-md-4 col-xl d-flex flex-column text-start flex-column gap-2 mt-3 mt-xl-0 px-xl-1">
<b>Email</b>
<span>{user.email}</span>
</div>
<div className="col-12 col-sm-6 col-md-4 col-xl d-flex flex-column text-start flex-column gap-2 mt-3 mt-xl-0 px-xl-1">
<b>Cargo</b>
<span>{user.role_id === 1 ? "Administrador" : "Utilizador"}</span>
</div>
<div className="col-12 col-sm-6 col-md-4 col-xl d-flex flex-column text-start flex-column gap-2 mt-3 mt-xl-0 px-xl-1">
<b>Criado a</b>
<span>{new Date(user.created_at).toLocaleDateString('pt-PT')}</span>
</div>
<div className="col-12 col-sm-6 col-md-4 col-xl d-flex flex-column text-start flex-column gap-2 mt-3 mt-xl-0 px-xl-1">
<b>Actualizado a</b>
<span>{new Date(user.updated_at).toLocaleDateString('pt-PT')}</span>
</div>
</div> */}
<div className="mt-5">
{formEdit ? (
<div className="my-3">
<span className={styles.title}>Editar Utilizador</span>
<form method="patch" onSubmit={update} id="formEditUser">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label>
<input type="text" className="form-control py-2" id="name" name="name" defaultValue={user?.name} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="email">Email</label>
<input type="email" className="form-control py-2" id="email" name="email" defaultValue={user?.email} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="role_id">Cargo</label>
<select className="form-select py-2" id="role_id" name="role_id" defaultValue={user?.role_id}>
<option value="1">Administrador</option>
<option value="2">Utilizador</option>
</select>
</div>
</div>
{error ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{(error as ApiErrorResponse).message}</p>
</div>
</div>
) : null}
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="button" className={`${styles.closeFormButton}`} onClick={() => setFormEdit(false)}> Cancelar <LuX className={`${styles.icon} mb-1`} /></button>
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
</div>
</form>
</div>
) : null}
</div>
</div>
) : (
<div className="text-center alert alert-danger mt-5 align-items-center">
<span>Utilizador não encontrado</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,123 @@
.container{
align-self: start;
width: 100%;
max-width: 1400px;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.userCard{
border-radius: var(--border-radius);
background-color: var(--bg-white);
}
.updateButton{
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity) !important;
}
}
.icon{
transition: all 0.3s ease;
}
.deleteButton, .cancelButton{
color: var(--text-primary-color);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
}
}
.userVideosWatched{
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
}
.closeFormButton{
background-color: var(--bg-primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color) !important;
.icon{
color: var(--text-primary-color) !important;
}
}
}
.submitFormButton{
background-color: var(--tertiary-color);
color: var(--text-white) !important;
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bs-primary-bg-subtle);
color: var(--tertiary-color) !important;
.icon{
color: var(--tertiary-color) !important;
}
}
}
.linkBack{
color: var(--text-black);
font-weight: 500;
transition: all 0.3s ease;
&:hover{
color: var(--primary-color);
}
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from "react";
import type { ApiErrorResponse, User } from "../../../types";
import { Link, Navigate } from "react-router";
import styles from "./styles.module.css";
import { LuPencil } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import { LuPlus } from "react-icons/lu";
import { Pagination, Table } from "react-bootstrap";
export default function Users() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string);
if (user.role_id !== 1) {
return <Navigate to="/dashboard" />;
}
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [selectedUsers, setSelectedUsers] = useState<string>("all");
const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [listTotal, setListTotal] = useState(0);
useEffect(() => {
index();
}, [currentPage, selectedUsers]);
async function index() {
setLoading(true);
try {
const response = await fetch(`http://127.0.0.1:8000/api/users?page=${currentPage}&filter=${selectedUsers}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setUsers(Array.isArray(data.data.data) ? data.data.data : []);
setLastPage(data.data.last_page);
setError(null);
} else {
setUsers([]);
setLastPage(1);
setListTotal(0);
setMessage((data as ApiErrorResponse).message);
setError(data as ApiErrorResponse);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
if (loading) {
return (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
if (error) {
return (
<div className="text-center mt-5">
<p>{(error as ApiErrorResponse).message}</p>
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0`}>
<h1 className={styles.title}>Utilizadores</h1>
<div className="row py-3 g-4">
<div className="col-12 col-sm-6 text-start px-2 ">
<label htmlFor="filter" className="form-label fw-bold text-start">Filtrar Utilizadores</label>
<select
className="form-control select-filter"
name="filter"
id="filter"
value={selectedUsers}
onChange={(e) => {
setSelectedUsers(e.target.value);
setCurrentPage(1);
}}
>
<option key="all" value="all">Todos</option>
<option key="admin" value="admin">Administradores</option>
<option key="user" value="user">Utilizadores</option>
</select>
</div>
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center px-0">
<Link to="/admin/create-user" className={`${styles.createButton} text-decoration-none`}><LuPlus className="mb-1" /> Novo Utilizador</Link>
</div>
</div>
{users.length > 0 && (
<div>
<div className={`${styles.table} mt-3 mb-2`}>
<Table responsive className="mb-0">
<thead className="table-light">
<tr>
<th className="py-3">ID</th>
<th className="py-3">Name</th>
<th className="py-3">Email</th>
<th className="py-3">Cargo</th>
<th className="py-3">...</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td className="py-3">{user.id}</td>
<td className="py-3">{user.name}</td>
<td className="py-3">{user.email}</td>
<td className="py-3 px-3" >
<span className={`p-2 rounded-3 ${user.role_id === 1 ? 'text-primary bg-primary-subtle' : 'text-secondary bg-secondary-subtle'}`}>
{user.role_id === 1 ? "Administrador" : "Utilizador"}
</span>
</td>
<td className="py-3">
<div className="d-flex gap-2 justify-content-evenly">
<Link to={`/admin/user/${user.id}`}><LuPencil className="text-red-500" /></Link>
</div>
</td>
</tr>
))}
</tbody>
</Table>
</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)}
/>
{currentPage > 3 && <Pagination.Item onClick={() => setCurrentPage(1)}>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)}>
{p}
</Pagination.Item>
))}
{currentPage < lastPage - 3 && <Pagination.Ellipsis disabled />}
{currentPage < lastPage - 2 && (
<Pagination.Item onClick={() => setCurrentPage(lastPage)}>{lastPage}</Pagination.Item>
)}
<Pagination.Next
disabled={currentPage >= lastPage}
onClick={() => setCurrentPage((p) => p + 1)}
/>
</Pagination>
{/* <button type="button" className="btn btn-outline-secondary btn-sm" disabled={currentPage <= 1} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}>
<LuChevronLeft className="fs-5" />
</button>
<span className="text-muted small">
Página {currentPage} de {lastPage}
</span>
<button type="button" className="btn btn-outline-secondary btn-sm" disabled={currentPage >= lastPage} onClick={() => setCurrentPage((p) => Math.min(lastPage, p + 1))}>
<LuChevronRight className="fs-5" />
</button> */}
</div>
</div>
)}
{users.length === 0 && !error && (
<div className="text-center mt-5 d-flex flex-column align-items-center justify-content-center">
{selectedUsers === "all" && listTotal === 0 ? (
<>
<span className="text-muted">Sem registo de utilizadores</span>
<Link to="/admin/create-user" className={`${styles.createButton} text-decoration-none`}><LuPlus className="mb-1" /> Novo Utilizador</Link>
</>
) : (
<span className="text-muted">Nenhum utilizador encontrado com o filtro selecionado</span>
)}
</div>
)}
{error && <p>{(error as ApiErrorResponse).message}</p>}
</div>
)
}

View File

@@ -0,0 +1,56 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.table{
overflow-y: scroll;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
}
.createButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.pagination .page-item.active .page-link {
background-color: var(--bg-primary-color-opacity) !important;
border-color: var(--border-primary-color) !important;
color: var(--text-primary-color) !important;
}
.page-link{
color: var(--text-neutral-color) !important;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,37 @@
import { Link, 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";
export default function ProtectedLayout() {
const navigate = useNavigate();
const token = localStorage.getItem("token");
if (!token) {
navigate("/login");
}
useEffect(() => {
if (!token) {
navigate("/login");
}
}, [token]);
return (
<>
<div className={styles.mainContainer}>
<Sidebar />
<section className={styles.contentArea}>
<Header />
<main className={styles.main}>
<Outlet />
</main>
{/* <Footer /> */}
</section>
</div>
</>
);
}

View File

@@ -0,0 +1,67 @@
import { Outlet, useNavigate } from "react-router";
import { useEffect, useState } from "react";
import type { ApiErrorResponse, User } from "../../../types";
export default function AdminLayout() {
const navigate = useNavigate();
const userRaw = localStorage.getItem("user");
const [user, setUser] = useState<User | null>(userRaw ? JSON.parse(userRaw) : null);
/* Verificação do role_id no frontend das páginas do admin */
const [checkingRole, setCheckingRole] = useState<boolean>(true);
const [error, setError] = useState<ApiErrorResponse | null>(null);
useEffect(() => {
getVerifyRole();
}, []);
async function getVerifyRole() {
try {
const response = await fetch("http://127.0.0.1:8000/api/me", {
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
setCheckingRole(false);
navigate("/dashboard");
return;
}
const data = await response.json();
console.log("role_id:", data?.data?.role_id);
if (data?.data?.role_id !== 1) {
setError({
message: "Acesso negado. Apenas administradores podem aceder a esta página",
data: null,
errors: {},
});
navigate("/dashboard");
return;
}
} catch (error) {
setCheckingRole(false);
navigate("/dashboard");
return;
}
}
useEffect(() => {
if (!user || user.role_id !== 1) {
navigate("/dashboard", { replace: true });
}
}, [user, navigate]);
if (!user || user.role_id !== 1) return null;
return (
<Outlet />
);
/* Criado o layout para o admin, o proximo passo é aninhar este layout em routes.tsx no frontend */
}

View File

@@ -0,0 +1,168 @@
import { useState } from "react";
import { Link } from "react-router";
import styles from "./styles.module.css";
import { LuArrowLeft } from "react-icons/lu";
import { LuPlus } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2";
export default function CreateUser() {
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [password_confirmation, setPasswordConfirmation] = useState<string>("");
const [role_id, setRoleId] = useState("2");
const [checkingRole, setCheckingRole] = useState<boolean>(true);
const [creating, setCreating] = useState(false);
setTimeout(() => {
setCheckingRole(false);
}, 2000);
async function create() {
//validação dos campos
if (name.length < 3) {
Swal.fire({
title: "Nome deve ter pelo menos 3 caracteres",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
} else if (!email.includes('@')) {
Swal.fire({
title: "Email inválido",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
} else if (password !== password_confirmation) {
Swal.fire({
title: "As senhas não coincidem",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
} else if (password.length < 6) {
Swal.fire({
title: "Password deve ter pelo menos 6 caracteres",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
}
if (role_id === "") {
Swal.fire({
title: "Cargo é obrigatório",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
}
setCreating(true);
const payload = {
name: name,
email: email,
password: password,
password_confirmation: password_confirmation,
role_id: role_id,
};
const response = await fetch("http://127.0.0.1:8000/api/create-user", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify(
payload
),
});
const data = await response.json();
if (response.ok) {
setCreating(false);
setName("");
setEmail("");
setPassword("");
setPasswordConfirmation("");
setRoleId("2");
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
} else {
setCreating(false);
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
if (checkingRole) {
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
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="/admin/users"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Utilizadores</span> </Link></button>
</div>
<h1 className={styles.title}>Criar Utilizador</h1>
<div>
<div>
<div className="row g-3">
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label>
<input type="text" className="form-control py-2" id="name" name="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="email">Email</label>
<input type="email" className="form-control py-2" id="email" name="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="password">Password</label>
<input type="password" className="form-control py-2" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="password_confirmation">Confirme a Password</label>
<input type="password" className="form-control py-2" id="password_confirmation" name="password_confirmation" value={password_confirmation} onChange={(e) => setPasswordConfirmation(e.target.value)} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="role_id">Cargo</label>
<select className="form-select py-2" id="role_id" name="role_id" value={role_id} onChange={(e) => setRoleId(e.target.value)}>
<option value="2">Utilizador</option>
<option value="1">Administrador</option>
</select>
</div>
</div>
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="submit" className={`${styles.createButton}`} onClick={() => { create(); setCreating(true); }} disabled={creating}>{creating ? <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> : <LuPlus className="mb-1 text-white" />} Adicionar</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.createButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}

View File

@@ -0,0 +1,312 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import type { Category } from "../../../../types";
import styles from "./styles.module.css";
import { LuArrowLeft, LuPlus } from "react-icons/lu";
import { LuUpload } from "react-icons/lu";
import Swal from "sweetalert2";
import { CgSpinner } from "react-icons/cg";
export default function CreateVideo() {
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [videoFile, setVideoFile] = useState<File | null>(null);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [tags, setTags] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [category_ids, setCategoryIds] = useState<string[]>([]);
const [checkingRole, setCheckingRole] = useState(true);
const [creating, setCreating] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
setTimeout(() => {
setCheckingRole(false);
}, 2000);
useEffect(() => {
getCategories();
}, []);
async function getCategories() {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setCategories(data.data);
} else {
setCategories([]);
}
}
async function createVideo() {
setCreating(true);
if (!videoFile || !thumbnailFile || !title || !description) {
Swal.fire({
title: "Vídeo e thumbnail são obrigatórios.",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
setCreating(false);
return;
}
const formData = new FormData();
formData.append("title", title);
formData.append("description", description);
formData.append("url", videoFile);
formData.append("thumbnail", thumbnailFile);
formData.append("tags", tags);
category_ids.forEach(id => {
formData.append("category_ids[]", id);
});
setIsUploading(true);
setUploadProgress(0);
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:8000/api/create-video");
xhr.setRequestHeader("Authorization", `Bearer ${localStorage.getItem("token")}`);
xhr.setRequestHeader("Accept", "application/json");
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setUploadProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
setIsUploading(false);
let data: any = null;
try {
data = xhr.responseText ? JSON.parse(xhr.responseText) : null;
} catch {
Swal.fire({
title: data.message as string,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
setUploadProgress(100);
setIsUploading(false);
setTitle("");
setDescription("");
setVideoFile(null);
setThumbnailFile(null);
setTags("");
setCategoryIds([]);
setCreating(false);
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
} else {
setCreating(false);
setIsUploading(false);
Swal.fire({
title: data.message as string,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
};
xhr.onerror = () => {
setIsUploading(false);
setCreating(false);
Swal.fire({
title: "Falha de rede durante upload.",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
};
xhr.send(formData);
}
function handleCreateCategory() {
Swal.fire({
title: 'Criar nova categoria',
input: 'text',
inputPlaceholder: 'Categoria...',
showCancelButton: true,
confirmButtonText: 'Adicionar',
cancelButtonText: 'Cancelar',
confirmButtonColor: 'var(--primary-color)',
inputValidator: (value) => {
if (!value) {
return 'Indique o nome da categoria!';
}
}
}).then((result) => {
if (result.isConfirmed) {
const categoryName = result.value;
createCategory(categoryName);
}
});
}
async function createCategory(categoryName: string) {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify({
name: categoryName
})
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
getCategories();
} else {
Swal.fire({
title: data.message as string,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value, checked } = e.target;
setCategoryIds(prev =>
checked ? [...prev, value] : prev.filter(id => id !== value)
);
};
if (checkingRole) {
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<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>
<h1 className={styles.title}>Adicionar Vídeo</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>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" placeholder="Insira o título do vídeo" 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>
<textarea rows={5} className="form-control py-2" id="description" name="description" placeholder="Insira uma descrição para o vídeo" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="col-12 px-1 text-start">
<label className="form-label fw-bold" htmlFor="tags">Palavras-chave <small className="text-muted">(separar por vírgulas)</small></label>
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" placeholder="Insira as palavras-chave do vídeo" value={tags} onChange={(e) => setTags(e.target.value)} />
</div>
<div className="col-12 col-sm-6 px-1 text-start">
<label className="form-label fw-bold" htmlFor="url">Adicionar vídeo</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" /> {videoFile ? videoFile.name : "Carregar vídeo"} </span>
<input type="file" className="form-control py-2 text-truncate" id="url" name="url" accept="video/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setVideoFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 col-sm-6 px-1 text-start">
<label className="form-label fw-bold" htmlFor="thumbnail">Adicionar 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 imagem"} </span>
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 px-1 text-start">
<div className="d-flex">
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
</div>
<div>
{categories && categories.length > 0 ? (
categories.map((category) => (
<div key={category.id}>
<input type="checkbox" name="category_id" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox} />
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
</div>))
) : (
<div>
<span>Nenhuma categoria encontrada</span>
</div>
)}
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
</div>
</div>
</div>
{/* {error ? (
<div className="alert alert-danger mt-4">
<p className="mb-1">{(error as ApiErrorResponse).message}</p>
{Object.keys((error as ApiErrorResponse).errors ?? {}).length > 0 ? (
<ul className="mb-0 small">
{Object.entries((error as ApiErrorResponse).errors).flatMap(([field, msgs]) =>
msgs.map((m) => (
<li key={`${field}-${m}`}>
<strong>{field}</strong>: {m}
</li>
))
)}
</ul>
) : null}
</div>
) : createVideoResponse?.message || createCategoryResponse?.message ? (
<div className="alert alert-success mt-4">
{createVideoResponse?.message || createCategoryResponse?.message}
</div>
) : null} */}
{isUploading && (
<div className="mt-3">
<div className="progress" role="progressbar" aria-valuenow={uploadProgress} aria-valuemin={0} aria-valuemax={100}>
<div
className="progress-bar"
style={{ width: `${uploadProgress}%` }}
>
{uploadProgress}%
</div>
</div>
<small className="text-muted">A carregar vídeo...</small>
</div>
)}
<button type="button" className={`${styles.btnAdicionarVideo} btn btn-primary mt-5`} onClick={() => { createVideo() }} disabled={creating}>{creating ? <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> : <LuPlus />} Adicionar vídeo</button>
</div>
)
}

View File

@@ -0,0 +1,78 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.btnAdicionarVideo{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.btnAdicionarCategoria{
background-color: transparent;
color: var(--primary-color);
border: none;
font-size: var(--size-font-subtitle);
padding: 0;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
color: var(--neutral-color);
}
}
.inputFiles{
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529 !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,188 @@
import { useState } from "react";
import { Link } from "react-router";
import styles from "./styles.module.css";
import { LuArrowLeft, LuPlus, LuUpload } from "react-icons/lu";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { pt } from "date-fns/locale";
import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2";
export default function CreateWorkshop() {
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [image, setImage] = useState<File | null>(null);
const [date, setDate] = useState<Date | null>(null);
const [time_start, setTimeStart] = useState<Date | null>(null);
const [time_end, setTimeEnd] = useState<Date | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [checkingRole, setCheckingRole] = useState(true);
const [creating, setCreating] = useState(false);
setTimeout(() => {
setCheckingRole(false);
}, 3000);
//função para formatar a data para o formato Local
function formatDateLocalISO(d: Date) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
async function createWorkshop() {
if (!title || !description || !image || !date || !time_start || !time_end) {
Swal.fire({
title: "Todos os campos são obrigatórios",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
}
setCreating(true);
const formData = new FormData();
formData.append("title", title);
formData.append("description", description);
formData.append("image", image as File);
formData.append("date", formatDateLocalISO(date));
formData.append("time_start", time_start.toTimeString().slice(0, 5));
formData.append("time_end", time_end.toTimeString().slice(0, 5));
const response = await fetch("http://127.0.0.1:8000/api/create-workshop", {
method: "POST",
headers: {
'Accept': 'application/json',
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
});
setIsLoading(true);
const data = await response.json();
if (response.ok) {
setTitle("");
setDescription("");
setImage(null);
setDate(null);
setTimeStart(null);
setTimeEnd(null);
setIsLoading(false);
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
setCreating(false);
} else {
Swal.fire({
title: data.message as string,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
setCreating(false);
}
}
if (checkingRole) {
return (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/workshops"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Workshops</span> </Link></button>
</div>
<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>
<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>
<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>
<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>
<DatePicker
selected={date}
onChange={(d: Date | null) => setDate(d)}
dateFormat="dd/MM/yyyy"
locale={pt}
className="form-control py-2"
placeholderText="Seleciona a data"
id="date"
/>
</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>
<DatePicker
selected={time_start}
onChange={(t: Date | null) => setTimeStart(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de início"
id="time_start"
minTime={time_end
? new Date(new Date(time_end).getTime() - 30 * 60 * 1000)
: new Date(new Date().setHours(9, 0, 0, 0))
}
maxTime={new Date(new Date().setHours(23, 59, 59, 999))}
/>
</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>
<DatePicker
selected={time_end}
onChange={(t: Date | null) => setTimeEnd(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de término"
id="time_end"
minTime={time_start
? new Date(new Date(time_start).getTime() + 30 * 60 * 1000)
: new Date(new Date().setHours(23, 59, 59, 999))
}
maxTime={new Date(new Date().setHours(23, 59, 59, 999))}
/>
</div>
</div>
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="submit" onClick={createWorkshop} className={`${styles.btnAdicionarWorkshop}`} disabled={isLoading}><LuPlus className="mb-1 text-white" /> {creating ? <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> : "Adicionar Workshop"}</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.inputFiles{
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529 !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.btnAdicionarWorkshop{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,349 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import type { ApiErrorResponse, Category, Video } from "../../../../types";
import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
import { LuUpload, LuPlus, LuCheck, LuTrash2, LuArrowLeft } from "react-icons/lu";
import Swal from "sweetalert2";
export default function editVideo() {
const { id } = useParams();
const [video, setVideo] = useState<Video | null>(null);
const [loading, setLoading] = useState(true);
const [categories, setCategories] = useState<Category[]>([]);
const [category_ids, setCategoryIds] = useState<string[]>([]);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [isActive, setIsActive] = useState<boolean>();
const [error, setError] = useState<ApiErrorResponse | null>(null);
const navigate = useNavigate();
useEffect(() => {
getVideo();
getCategories();
}, [id]);
useEffect(() => {
if (!video) return;
setCategoryIds(video.categories?.map((category) => category.id.toString()) ?? []);
setIsActive(Boolean(video.is_active));
}, [video]);
async function getVideo() {
setLoading(true);
try {
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setVideo(data.data);
} else {
setError(data as ApiErrorResponse);
setVideo(null);
}
} catch {
setError(error as ApiErrorResponse);
setVideo(null);
} finally {
setLoading(false);
}
}
async function destroy(id: number) {
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: true,
showCloseButton: false,
allowOutsideClick: false,
confirmButtonText: 'Voltar',
confirmButtonColor: 'var(--primary-color)',
}).then(() => {
navigate('/videos');
});
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
function handleDeleteVideo() {
Swal.fire({
text: 'Tem a certeza que deseja apagar este vídeo?',
showCancelButton: true,
confirmButtonText: 'Sim, apagar',
confirmButtonColor: 'var(--primary-color)',
cancelButtonText: 'Cancelar',
}).then((result) => {
if (result.isConfirmed) {
destroy(video?.id as number);
}
});
}
async function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
// Garante categorias atualizadas via state (checkboxes controladas)
formData.delete("category_ids[]");
category_ids.forEach((categoryId) => {
formData.append("category_ids[]", categoryId);
});
// Mantém compatibilidade de upload de ficheiro com Laravel
// quando a rota aceita PATCH (method spoofing)
formData.append("_method", "PATCH");
const response = await fetch(`http://127.0.0.1:8000/api/edit-video/${id}`, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: formData,
});
const data = await response.json();
if (response.ok) {
setVideo(data.data);
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
}
getVideo();
return;
}
async function getCategories() {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setCategories(data.data);
} else {
setCategories([]);
}
}
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value, checked } = e.target;
setCategoryIds(prev =>
checked ? [...prev, value] : prev.filter(id => id !== value)
);
};
function handleCreateCategory(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
Swal.fire({
title: 'Criar nova categoria',
input: 'text',
inputPlaceholder: 'Categoria...',
showCancelButton: true,
confirmButtonText: 'Adicionar',
cancelButtonText: 'Cancelar',
confirmButtonColor: 'var(--primary-color)',
inputValidator: (value) => {
if (!value) {
return 'Indique o nome da categoria!';
}
}
}).then((result) => {
if (result.isConfirmed) {
const categoryName = result.value;
createCategory(categoryName);
}
});
}
async function createCategory(categoryName: string) {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify({
name: categoryName
})
});
const data = await response.json();
if (response.ok) {
getCategories();
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
return (
<>
{loading ? (
<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 dados do 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 ? (
<div className="my-3">
<div className="mt-5">
<span className={styles.title}>Alterar dados do vídeo</span>
<div className="row mx-auto g-4">
<form method="patch" onSubmit={update}>
<input type="hidden" name="video_id" value={video?.id} />
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="title">Título</label>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" defaultValue={video?.title} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
<textarea rows={5} className="form-control py-2" id="description" name="description" defaultValue={video?.description} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="tags">Tags <small className="text-muted">(separar por vírgulas)</small></label>
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" defaultValue={video?.tags} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="thumbnail">Atualizar thumbnail</label>
<div className="position-relative">
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1 me-2" /> {thumbnailFile ? thumbnailFile.name : "Carregar nova imagem"} </span>
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 px-1 text-start mb-3">
<div className="d-flex">
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
</div>
<div>
{categories && categories.length > 0 ? (
categories.map((category) => (
<div key={category.id}>
<input type="checkbox" name="category_ids[]" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox} />
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
</div>))
) : (
<div>
<span>Nenhuma categoria encontrada</span>
</div>
)}
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
</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>
<div className="d-flex gap-4">
<span>
<input
type="radio"
name="is_active"
id="is_active_true"
value="1"
checked={isActive === true}
onChange={() => setIsActive(true)}
/>
<label className="ms-1" htmlFor="is_active_true">Ativo</label>
</span>
<span>
<input
type="radio"
name="is_active"
id="is_active_false"
value="0"
checked={isActive === false}
onChange={() => setIsActive(false)}
/>
<label className="ms-1" htmlFor="is_active_false">Inativo</label>
</span>
</div>
{/* <select className="form-control py-2 text-truncate" id="is_active" name="is_active" defaultValue={video?.is_active ? "Ativo" : "Inativo"} onChange={(e) => setIsActive(e.target.value)}>
<option value="1">Ativo</option>
<option value="0">Inativo</option>
</select> */}
</div>
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center" >
<button type="submit" className={`${styles.submitButton} bg-primary`}>Submeter Dados <LuCheck className="mb-1 text-white" /></button>
<button type="button" className={`${styles.deleteButton}`} onClick={() => handleDeleteVideo()}>Apagar <LuTrash2 className="mb-1 text-white" /></button>
</div>
</form>
</div>
</div>
</div>
) : (
<div className="text-center alert alert-danger mt-5 align-items-center">
<span>{error?.message}</span>
</div>
)}
</div>
)}
</>
)
}

View File

@@ -0,0 +1,104 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.statusAlert{
width: 100px;
border-radius: var(--border-radius-input);
}
.imgThumbnail{
max-width: 400px;
border-radius: var(--border-radius);
}
.inputFiles{
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529 !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.btnAdicionarCategoria{
background-color: transparent;
color: var(--primary-color);
border: none;
font-size: var(--size-font-subtitle);
padding: 0;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
color: var(--neutral-color);
}
}
.updateButton, .submitButton{
background-color: var(--text-primary);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.deleteButton, .cancelButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,455 @@
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, LuPlus } 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";
export default function Workshop() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string) as User;
const isAdmin = user.role_id === 1;
const { id } = useParams();
const [loading, setLoading] = useState(true);
const [workshop, setWorkshop] = useState<Workshop | null>(null);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [formEdit, setFormEdit] = useState<boolean>(false);
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [image, setImage] = useState<File | null>(null);
const [date, setDate] = useState<Date | null>(null);
const [time_start, setTimeStart] = useState<Date | null>(null);
const [time_end, setTimeEnd] = useState<Date | null>(null);
const [status, setStatus] = useState<string>("");
const [messageDeleteWorkshop, setMessageDeleteWorkshop] = useState<string>("");
const [messageUpdateWorkshop, setMessageUpdateWorkshop] = useState<string>("");
const [listagemInscritos, setListagemInscritos] = useState<boolean>(false);
/* Para enviar a data no formato que o backend espera */
function formatDateToYmd(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatTimeToHm(value: Date): string {
const hours = String(value.getHours()).padStart(2, "0");
const minutes = String(value.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
useEffect(() => {
getWorkshop();
}, [id]);
async function getWorkshop() {
setLoading(true);
try {
const response = await fetch(`http://127.0.0.1:8000/api/edit-workshop/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setWorkshop(data.data);
setStatus(data.data.status);
setLoading(false);
} else {
setWorkshop(null);
setError(data as ApiErrorResponse);
setLoading(false);
}
} catch {
console.error(error);
} finally {
setLoading(false);
}
}
async function update(event?: React.FormEvent<HTMLFormElement>, overrides?: { status?: string }) {
event?.preventDefault();
if (!workshop) return;
const formData = new FormData();
formData.append("title", title || workshop.title);
formData.append("description", description || workshop.description);
formData.append("date", date ? formatDateToYmd(date) : workshop.date);
formData.append("time_start", time_start ? formatTimeToHm(time_start) : workshop.time_start.slice(0, 5));
formData.append("time_end", time_end ? formatTimeToHm(time_end) : workshop.time_end.slice(0, 5));
formData.append("status", overrides?.status || status || workshop.status);
if (image) {
formData.append("image", image);
}
const response = await fetch(`http://127.0.0.1:8000/api/edit-workshop/${id}`, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: formData,
});
const data = await response.json();
if (response.ok) {
setMessageUpdateWorkshop(data.message as string);
setTimeout(() => {
setMessageUpdateWorkshop("");
}, 3000)
setError(null);
getWorkshop();
setFormEdit(false);
} else {
setMessageUpdateWorkshop("");
setError(data as ApiErrorResponse);
}
}
async function inscrever() {
}
/* async function destroy(id: number) {
const response = await fetch(`http://127.0.0.1:8000/api/workshop/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setMessageDeleteWorkshop(data.message as string);
setError(null);
getWorkshop();
} else {
setMessageDeleteWorkshop("");
setError(data as ApiErrorResponse);
}
}
function handleDeleteWorkshop() {
Swal.fire({
text: 'Tem a certeza que deseja cancelar este workshop?',
showCancelButton: true,
confirmButtonText: 'Sim, apagar',
confirmButtonColor: 'var(--primary-color)',
cancelButtonText: 'Cancelar',
}).then((result) => {
if (result.isConfirmed) {
destroy(workshop?.id as number);
}
});
} */
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 dados do workshop...</span>
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0`}>
<div className={"text-start mb-3"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}>
<Link className={`${styles.link} text-decoration-none`} to="/workshops">
<LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Workshops</span>
</Link>
</button>
</div>
{workshop ? (
<>
<div className={`${styles.container} d-flex flex-column gap-2`}>
{messageUpdateWorkshop ? (
<div className="">
<div className="alert alert-success mt-4">
{messageUpdateWorkshop}
</div>
</div>
) : null}
<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">
{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="fw-bold">{workshop.status === "pending" ? (<><LuClock3 className="mb-1" /> Agendado</>) : workshop.status === "realized" ? (<><LuCheck className="mb-1" /> Realizado</>) : (<><LuX className="mb-1" /> Cancelado</>)}</span>
</span>
) : null}
<div className="d-flex flex-wrap text-start gap-1 mt-2">
<span className={`${styles.dateWorkshop} text-start d-inline-block`}>
<LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} />{workshop.date.split("-").reverse().join("-")}
</span>
<span className={`${styles.timeWorkshop} text-start d-inline-block`}>
<LuClock3 className={`${styles.iconClock} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
</span>
</div>
</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>
) : isAdmin && workshop.users.length > 0 ? (
<div className="d-flex text-start flex-wrap gap-1 mt-2">
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}><LuUsers className="mb-1 me-2" /> {workshop.users.length === 1 ? "1 utilizador inscrito" : `${workshop.users.length} utilizadores inscritos`}</span>
<a type="button" className="align-content-center text-muted text-decoration-none fw-semibold fs-6" onClick={() => { setListagemInscritos(true); setFormEdit(false) }}>
(<LuClipboardList className="mb-1 me-2" /> Ver utilizadores)
</a>
</div>
) : null}
{!isAdmin ? (
<div className={`${styles.buttonsContainer} mt-3 d-flex flex-wrap gap-2`}>
<button type="button" className={`${styles.btnInscrever} btn text-center mt-3 py-2 px-5 text-decoration-none`} onClick={inscrever}>Inscrever-Me</button>
</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>
{!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={() => update(undefined, { status: "canceled" })}><LuTrash2 className="mb-1 btn-danger" /> Cancelar Workshop</button>
) : workshop.status === "canceled" ? (
<button type="button" className={`${styles.activateButton}`} onClick={() => update(undefined, { status: "pending" })}><LuPlus className="mb-1" /> Ativar Workshop</button>
) : null}
</div>
) : null}
<div>
<div className={`${styles.users} mt-4`}>
{listagemInscritos ? (
<div className="table-responsive">
<button type="button" className={`${styles.btnClose} d-flex float-end`} onClick={() => setListagemInscritos(false)}><LuX className={`${styles.iconClose} mb-1`} /> </button>
<table className="table table-striped table-hover align-middle mt-3">
<thead className="table-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Nome</th>
<th scope="col">Email</th>
<th scope="col">Inscrito em</th>
</tr>
</thead>
<tbody>
{workshop.users.map((user) => (
<tr key={user.id}>
<td className="text-muted fw-semibold">#{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
<td>
{user.pivot?.created_at
? new Date(user.pivot.created_at).toLocaleString("pt-PT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</div>
</div>
{formEdit ? (
<form className="mt-3" 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>
<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>
<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>
<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"}
</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>
<DatePicker
selected={date ?? new Date(workshop.date)}
onChange={(d: Date | null) => setDate(d)}
dateFormat="dd/MM/yyyy"
locale={pt}
className="form-control py-2"
placeholderText="Seleciona a data"
id="date"
name="date"
minDate={new Date()}
/>
</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>
<DatePicker
selected={time_start ? time_start : new Date(`1970-01-01T${workshop.time_start}`)}
onChange={(t: Date | null) => setTimeStart(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de início"
id="time_start"
name="time_start"
minTime={new Date(new Date().setHours(9, 0, 0, 0))}
maxTime={new Date(new Date().setHours(22, 0, 0, 0))}
/>
</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>
<DatePicker
selected={time_end ? time_end : new Date(`1970-01-01T${workshop.time_end}`)}
onChange={(t: Date | null) => setTimeEnd(t)}
showTimeSelect
showTimeSelectOnly
timeIntervals={10}
timeFormat="HH:mm"
dateFormat="HH:mm"
locale={pt}
className="form-control py-2"
placeholderText="Hora de término"
id="time_end"
name="time_end"
minTime={time_start
? new Date(new Date(time_start).getTime() + 30 * 60 * 1000)
: new Date(new Date().setHours(23, 59, 59, 999))
}
maxTime={new Date(new Date().setHours(22, 0, 0, 0))}
/>
</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">
<span>
<input
type="radio"
name="status"
id="status_pending"
value="pending"
checked={status === "pending"}
onChange={() => setStatus("pending")}
/>
<label className="ms-1" htmlFor="status_pending">Agendado</label>
</span>
<span>
<input
type="radio"
name="status"
id="status_realized"
value="realized"
checked={status === "realized"}
onChange={() => setStatus("realized")}
/>
<label className="ms-1" htmlFor="status_realized">Realizado</label>
</span>
<span>
<input
type="radio"
name="status"
id="status_canceled"
value="canceled"
checked={status === "canceled"}
onChange={() => setStatus("canceled")}
/>
<label className="ms-1" htmlFor="status_canceled">Cancelado</label>
</span>
</div>
</div>
{error ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{(error as ApiErrorResponse).message}</p>
</div>
</div>
) : 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>
</div>
</div>
</form>
) : null}
</>
) : messageDeleteWorkshop ? (
<div className="alert alert-success mt-4">{messageDeleteWorkshop}</div>
) : (
<div className="text-center alert alert-danger mt-5 align-items-center">
<span>Workshop não encontrado</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
.container{
width: 100%;
max-width: 1400px;
align-self: center;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.subtitle{
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
.thumbnail{
width: 300px;
height: 250px;
object-fit: cover;
border-radius: var(--border-radius);
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-primary-color-opacity);
color: var(--text-black);
font-size: 18px;
font-weight: 700;
border-radius: var(--border-radius-button);
padding: 5px 8px;
width: 180px;
}
.contagemUtilizadoresInscritos{
color: var(--text-black);
padding: 3px 9px;
font-weight: 800;
}
.btnClose{
background-color: transparent;
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-subtitle);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
}
}
.iconCalendar,.iconClock,.iconClose{
color: var(--text-primary-color);
}
.iconWarning{
color: var(--neutral-color);
}
.updateButton{
background-color: var(--text-primary);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity) !important;
color: var(--text-tertiary-color) !important;
.icon{
color: var(--text-tertiary-color) !important;
}
}
}
.btnInscrever{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-primary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
background-color: var(--neutral-color);
color: var(--text-white);
}
}
.deleteButton, .cancelButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
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;
color: var(--text-primary-color) !important;
.icon{
color: var(--text-primary-color) !important;
}
}
}
.activateButton{
background-color: var(--bs-success);
color: var(--text-white);
border: none;
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-success-color-opacity) !important;
color: var(--success-color) !important;
.icon{
color: var(--text-success-color) !important;
}
}
}
.btnInscritos{
color: var(--text-neutral-color);
border-radius: var(--border-radius-button);
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-white);
}
}
:global(.react-datepicker__header),
:global(.react-datepicker__header--time) {
display: none !important;
}
:global(.react-datepicker__time) {
width: 0px !important;
}
.react-datepicker__day--disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,40 @@
.mainContainer{
display: flex;
width: 100%;
height: 100vh;
}
.contentArea {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
.main{
flex: 1;
background: var(--bg-grey);
overflow: auto;
padding: 20px;
justify-items: center;
}
input:focus, input:active{
border: none;
outline: none;
}
.animateSpin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,256 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import type { ApiErrorResponse, User, Video, Workshop } from "../../../../types";
import styles from "./styles.module.css";
import { LuCheck, LuTrash2, LuX, LuPencil, LuArrowLeft } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2";
import { useGetWorkshops } from "../../../../hooks/useGetWorkshops";
export default function User() {
const { id } = useParams();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [formEdit, setFormEdit] = useState<boolean>(false);
const [workshops, setWorkshops] = useState<Workshop[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const { getWorkshops } = useGetWorkshops();
const navigate = useNavigate();
useEffect(() => {
const fetchAll = async () => {
const [workshopsData] = await Promise.all([
getWorkshops(),
]);
setWorkshops(workshopsData as Workshop[]);
getUser();
};
fetchAll();
}, [id]);
async function getUser() {
setLoading(true);
try {
const response = await fetch(`http://127.0.0.1:8000/api/get-user/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setUser(data.data);
} else {
setError(data as ApiErrorResponse);
setUser(null);
}
} catch (error) {
setError(error as ApiErrorResponse);
setUser(null);
} finally {
setLoading(false);
}
}
async function destroy(id: number) {
const response = await fetch(`http://127.0.0.1:8000/api/edit-user/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: true,
showCloseButton: true,
allowOutsideClick: false,
confirmButtonText: 'Voltar',
confirmButtonColor: 'var(--primary-color)',
}).then(() => {
navigate('/admin/users');
});
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
function handleDeleteUser() {
Swal.fire({
text: 'Tem a certeza que deseja apagar este utilizador?',
showCancelButton: true,
confirmButtonText: 'Sim, apagar',
confirmButtonColor: 'var(--primary-color)',
cancelButtonText: 'Cancelar',
}).then((result) => {
if (result.isConfirmed) {
destroy(user?.id as number);
}
});
}
async function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const user_id = formData.get("user_id");
const name = formData.get("name");
const email = formData.get("email");
const role_id = formData.get("role_id");
const payload = {
user_id: user_id,
name: name,
email: email,
role_id: role_id,
};
const response = await fetch(`http://127.0.0.1:8000/api/edit-user/${id}`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok) {
setUser(data.data);
setFormEdit(false);
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
getUser();
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
if (loading) {
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return (
<div className={`${styles.container} p-1 p-xl-0`}>
<div className={"text-start mt-5"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/admin/users"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Utilizadores</span> </Link></button>
</div>
{ user ? (
<div className="my-3">
<span className={styles.title}>Dados do Utilizador </span>
<div className="row mt-5 justify-content-between">
<div className="col-12 col-lg-8 p-2 rounded-3">
<div className={`${styles.userCard} d-flex flex-column flex-md-row g-5 p-4 bg-white h-100`}>
<div className="col-12 col-md-6 d-flex flex-column text-start flex-column mt-xl-0">
<span className={`badge text-uppercase bg-primary fw-bold px-4 py-3 fs-6 mb-3 d-flex d-md-none align-self-center ${user.role_id === 1 ? 'bg-primary bg-gradient text-white' : 'bg-secondary bg-gradient text-white'} `} style={{ width: "fit-content" }}>{user.role_id === 1 ? "Administrador" : "Utilizador"}</span>
<span className="fw-bold fs-4">{user.name}</span>
<span className="fs-5 flex-grow-1">{user.email}</span>
<div className="d-flex flex-column">
<span className="d-flex justify-content-start text-black text-muted flex-wrap flex-sm-nowrap fs-6 fw-medium mt-4 py-0" style={{ maxWidth: "275px" }}>Membro desde: <b className="fw-bold fs-6">{new Date(user.created_at).toLocaleDateString('pt-PT')}</b></span>
<span className="d-flex justify-content-start text-black text-muted flex-wrap flex-sm-nowrap fs-6 fw-medium py-0" style={{ maxWidth: "275px" }}>Última actualização: <b className="fw-bold fs-6">{new Date(user.updated_at).toLocaleDateString('pt-PT')}</b></span>
</div>
</div>
<div className="col-12 col-md-6 d-flex flex-column gap-2 text-end align-items-start align-items-md-end mt-0 justify-content-between mx-auto mx-md-0">
<span className={`badge text-uppercase d-none d-md-flex fw-bold px-4 py-3 fs-6 mt-0 ${user.role_id === 1 ? 'bg-primary bg-gradient text-white' : 'bg-secondary bg-gradient text-white'}`} style={{ width: "fit-content" }}>{user.role_id === 1 ? "Administrador" : "Utilizador"}</span>
<div className="buttonsContainer mt-4 mt-md-5 d-flex gap-2 justify-content-center mx-auto mx-md-0 flex-wrap" >
<a className={`${styles.updateButton} text-decoration-none text-primary`} onClick={() => { setFormEdit(true); }}> Editar <LuPencil className={`${styles.icon} mb-1 text-primary`} /></a>
<a className={`${styles.deleteButton} text-decoration-none text-danger`} onClick={() => handleDeleteUser()}>Apagar <LuTrash2 className={`${styles.icon} mb-1 text-danger`} /></a>
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-4 p-2">
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-primary-color-opacity)" }}>Vídeos assistidos</span>
<span className="fs-2 fw-bold text-white">0/{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-6" style={{ color: "var(--bg-grey)" }}>Workshops inscrito</span>
<span className="fs-2 fw-bold text-white">{workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === user?.id)).length}</span>
</div>
</div>
</div>
</div>
<div className="mt-5">
{formEdit ? (
<div className="my-3">
<span className={styles.title}>Editar Utilizador</span>
<form method="patch" onSubmit={update} id="formEditUser">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label>
<input type="text" className="form-control py-2" id="name" name="name" defaultValue={user?.name} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="email">Email</label>
<input type="email" className="form-control py-2" id="email" name="email" defaultValue={user?.email} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="role_id">Cargo</label>
<select className="form-select py-2" id="role_id" name="role_id" defaultValue={user?.role_id}>
<option value="1">Administrador</option>
<option value="2">Utilizador</option>
</select>
</div>
</div>
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="button" className={`${styles.closeFormButton}`} onClick={() => setFormEdit(false)}> Cancelar <LuX className={`${styles.icon} mb-1`} /></button>
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
</div>
</form>
</div>
) : null}
</div>
</div>
) : (
<div className="text-center alert alert-danger mt-5 align-items-center">
<span>{error?.message}</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,123 @@
.container{
align-self: start;
width: 100%;
max-width: 1400px;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.userCard{
border-radius: var(--border-radius);
background-color: var(--bg-white);
}
.updateButton{
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity) !important;
}
}
.icon{
transition: all 0.3s ease;
}
.deleteButton, .cancelButton{
color: var(--text-primary-color);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
}
}
.userVideosWatched{
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
}
.closeFormButton{
background-color: var(--bg-primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color) !important;
.icon{
color: var(--text-primary-color) !important;
}
}
}
.submitFormButton{
background-color: var(--tertiary-color);
color: var(--text-white) !important;
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bs-primary-bg-subtle);
color: var(--tertiary-color) !important;
.icon{
color: var(--tertiary-color) !important;
}
}
}
.linkBack{
color: var(--text-black);
font-weight: 500;
transition: all 0.3s ease;
&:hover{
color: var(--primary-color);
}
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useState } from "react";
import { useDebounce } from "../../../../hooks/useDebounce";
import type { ApiErrorResponse, User } from "../../../../types";
import { Link, Navigate } from "react-router";
import styles from "./styles.module.css";
import { LuPencil, LuSettings2 } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import { LuPlus } from "react-icons/lu";
import { Dropdown, Form, Pagination, Table } from "react-bootstrap";
export default function Users() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string);
if (user.role_id !== 1) {
return <Navigate to="/dashboard" />;
}
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState<string>("");
const [selectedUsers, setSelectedUsers] = useState<string>("all");
const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [listTotal, setListTotal] = useState(0);
const [loadingUsers, setLoadingUsers] = useState(false);
const debouncedSearch = useDebounce(search, 500);
useEffect(() => {
index(debouncedSearch);
}, [currentPage, selectedUsers, debouncedSearch]);
async function index(debouncedSearch: string) {
setLoadingUsers(true);
try {
const response = await fetch(`http://127.0.0.1:8000/api/users?page=${currentPage}&filter=${selectedUsers}&search=${encodeURIComponent(debouncedSearch)}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setUsers(Array.isArray(data.data.data) ? data.data.data : []);
setLastPage(data.data.last_page);
setListTotal(data.data.total);
setLoadingUsers(false);
setError(null);
} else {
setUsers([]);
setLastPage(1);
setListTotal(0);
setLoadingUsers(false);
setError(data as ApiErrorResponse);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
setLoadingUsers(false);
}
}
if (loading) {
return (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
if (error) {
return (
<div className="text-center mt-5">
<p>{(error as ApiErrorResponse).message}</p>
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0`}>
<h1 className={styles.title}>Utilizadores</h1>
<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="Pesquise por nome ou email..."
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">
<Dropdown className="flex-grow-1" onSelect={(value) => {
if (value) {
setSelectedUsers(value);
setCurrentPage(1);
setLoadingUsers(true);
}
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100">
<LuSettings2 /> {selectedUsers === 'all' ? 'Todos' : selectedUsers === 'admin' ? 'Administradores' : 'Utilizadores'}
</Dropdown.Toggle>
<Dropdown.Menu className="text-center w-100">
<Dropdown.Item eventKey="all" active={selectedUsers === 'all'}>Todos</Dropdown.Item>
<Dropdown.Item eventKey="admin" active={selectedUsers === 'admin'}>Administradores</Dropdown.Item>
<Dropdown.Item eventKey="user" active={selectedUsers === 'user'}>Utilizadores</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4">
<Link to="/admin/create-user" className={`${styles.createButton} text-decoration-none`}><LuPlus className="mb-1" /> Novo Utilizador</Link>
</div>
</div>
{loadingUsers ? (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
) : users.length > 0 ? (
<div>
<div className={`${styles.table} mt-3 mb-2`}>
<Table responsive className="mb-0">
<thead className="table-light">
<tr>
<th className="py-3">ID</th>
<th className="py-3">Name</th>
<th className="py-3">Email</th>
<th className="py-3">Cargo</th>
<th className="py-3">...</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td className="py-3">{user.id}</td>
<td className="py-3">{user.name}</td>
<td className="py-3">{user.email}</td>
<td className="py-3 px-3" >
<span className={`p-2 rounded-3 ${user.role_id === 1 ? 'text-primary bg-primary-subtle' : 'text-secondary bg-secondary-subtle'}`}>
{user.role_id === 1 ? "Administrador" : "Utilizador"}
</span>
</td>
<td className="py-3">
<div className="d-flex gap-2 justify-content-evenly">
<Link to={`/admin/user/${user.id}`}><LuPencil className="text-red-500" /></Link>
</div>
</td>
</tr>
))}
</tbody>
</Table>
</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); setLoadingUsers(true); }}
/>
{currentPage > 3 && <Pagination.Item onClick={() => { setCurrentPage(1); setLoadingUsers(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); setLoadingUsers(true); }}>
{p}
</Pagination.Item>
))}
{currentPage < lastPage - 3 && <Pagination.Ellipsis disabled />}
{currentPage < lastPage - 2 && (
<Pagination.Item onClick={() => { setCurrentPage(lastPage); setLoadingUsers(true); }}>{lastPage}</Pagination.Item>
)}
<Pagination.Next
disabled={currentPage >= lastPage}
onClick={() => { setCurrentPage((p) => p + 1); setLoadingUsers(true); }}
/>
</Pagination>
</div>
</div>
) : users.length === 0 && !error && (
<div className="text-center mt-5 d-flex flex-column align-items-center justify-content-center">
{selectedUsers === "all" && listTotal === 0 && search === "" ? (
<>
<span className="text-muted">Sem registo de utilizadores</span>
</>
) : (
<span className="text-muted">Nenhum utilizador encontrado com o filtro selecionado</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,56 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.table{
overflow-y: scroll;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
}
.createButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.pagination .page-item.active .page-link {
background-color: var(--bg-primary-color-opacity) !important;
border-color: var(--border-primary-color) !important;
color: var(--text-primary-color) !important;
}
.page-link{
color: var(--text-neutral-color) !important;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,157 @@
import styles from "./styles.module.css";
import { LuMail, LuMapPin, LuPhone } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import { useState } from "react";
import type { ApiErrorResponse } from "../../../types";
import { Navigate } from "react-router";
import AccordionFAQS from "../../../components/accordion FAQ´s/accordionFAQS";
export default function Contactos() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string);
if (!user) {
return <Navigate to="/login" />;
}
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [sending, setSending] = useState<boolean>(false);
const [messageSuccess, setMessageSuccess] = useState<string>("");
async function sendMail(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formElement = event.currentTarget;
const formData = new FormData(formElement);
const name = formData.get("name");
const email = formData.get("email");
const subject = formData.get("subject");
const message = formData.get("message");
const payload = {
name: name,
email: email,
subject: subject,
message: message,
};
setSending(true);
const response = await fetch("http://127.0.0.1:8000/api/contact", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok) {
setError(null);
setSending(false);
setMessageSuccess(data.message);
setTimeout(() => {
setMessageSuccess("");
}, 3000)
formElement.reset();
} else {
setError(data as ApiErrorResponse);
setSending(false);
setMessageSuccess("");
}
}
return (
<div className={styles.container} >
<h1 className={styles.title}>Contactos</h1>
<div className="row">
<div className="col-12 col-lg-6 text-start d-flex flex-column gap-3 px-4 mt-3 mt-lg-0">
<div className="text-center text-sm-start">
<h2 className={styles.subtitle}>Os nossos dados</h2>
<span >Pode contactar-nos através do nosso email, telefone ou morada.</span>
</div>
<div className="d-flex flex-column gap-5 mt-4">
<a href="mailto:geral@livetech.pt" target="_blank" className={`${styles.contactBox} d-flex flex-column flex-sm-row gap-3 align-items-center p-4`}>
<LuMail className={styles.icon} size={30} />
<div className="d-flex flex-column text-center text-sm-start">
<span className="text-black text-muted d-none d-sm-flex">Email</span>
<span className={styles.contactData}>geral@livetech.pt</span></div>
</a>
<a href="tel:+351912345678" target="_blank" className={`${styles.contactBox} d-flex flex-column flex-sm-row gap-3 align-items-center p-4`}>
<LuPhone className={styles.icon} size={30} />
<div className="d-flex flex-column text-center text-sm-start">
<span className="text-black text-muted d-none d-sm-flex">Telefone</span>
<span className={styles.contactData}>+351 912 345 678</span></div>
</a>
<a href="https://maps.app.goo.gl/WQJBghKxshgPcs1v8" target="_blank" className={`${styles.contactBox} d-flex flex-column flex-sm-row gap-3 align-items-center p-4`}>
<LuMapPin className={styles.icon} size={30} />
<div className="d-flex flex-column text-center text-sm-start">
<span className="text-black text-muted d-none d-sm-flex">Morada</span>
<span className={styles.contactData}>Rua da Escola, 123, Lisboa, Portugal</span></div>
</a>
</div>
</div>
<div className="col-12 col-lg-6 text-start d-flex flex-column gap-4 px-4 mt-5 mt-lg-0">
<div className="text-center text-sm-start">
<h2 className={styles.subtitle}>Formulário de contacto</h2>
<span>Preencha o formulário abaixo para entrar em contacto connosco</span>
</div>
<div className="mt-4">
<form onSubmit={sendMail} className="pt-2">
<div className="row g-3 bg-white p-4 rounded-3">
<div className="col-12 col-md-6 text-start mt-0">
<label className={styles.label} htmlFor="name">Nome</label>
<input type="text" className="form-control py-2 border" placeholder="Nome completo" id="name" name="name" />
</div>
<div className="col-12 col-md-6 text-start mt-0">
<label className={styles.label} htmlFor="email">E-mail</label>
<input type="email" placeholder="E-mail" className="form-control py-2 border" id="email" name="email" />
</div>
<div className="col-12 text-start">
<label className={styles.label} htmlFor="subject">Assunto</label>
<select className="form-select py-2 border" id="subject" name="subject" defaultValue="">
<option value="" disabled>Assunto</option>
<option value="Suporte Técnico" >Suporte Técnico</option>
<option value="Sugestão">Sugestão</option>
<option value="Dúvida">Dúvida</option>
<option value="Outro">Outro</option>
</select>
</div>
<div className="col-12 text-start">
<label className={styles.label} htmlFor="message">Mensagem</label>
<textarea rows={5} className="form-control border py-2" id="message" name="message" placeholder="Escreva aqui a sua mensagem..." />
</div>
{error && (
<div className="col-12 text-center">
<span className="text-danger">{error.message}</span>
</div>
)}
{messageSuccess && (
<div className="col-12 text-center ">
<span className="text-success">{messageSuccess}</span>
</div>
)}
<div className="col-12 text-end">
<button type="submit" className={`${styles.submitButton}`} disabled={sending}>{sending ? (<><span>A enviar...</span> <CgSpinner className={`${styles.animateSpin} mb-1`} size={20} /></>) : "Submeter"}</button>
</div>
</div>
</form>
</div>
</div>
<div className="col-12 mt-5 text-center">
<span className={`${styles.title}`}>Perguntas Frequentes</span>
<div className="row mt-4">
<div className="col-12 mx-auto">
<AccordionFAQS />
</div>
</div>
</div>
</div>
</div >
);
}

View File

@@ -0,0 +1,70 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.subtitle{
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
.contactBox{
border-radius: var(--border-radius);
background-color: var(--bg-white);
text-decoration: none;
}
.icon{
color: var(--text-primary-color);
}
.contactData{
color: var(--text-black);
font-size: var(--size-font-text);
font-weight: 500;
text-decoration: none;
}
.label{
color: var(--text-primary-color);
font-size: var(--size-font-small);
font-weight: 500;
}
.submitButton{
background-color: var(--primary-color);
color: var(--text-white);
border-radius: var(--border-radius-button);
border: none;
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--primary-color) !important;
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,368 @@
import { useEffect, useState } from "react";
import type { ApiErrorResponse, User, Video, Workshop } from "../../../types";
import styles from "./styles.module.css";
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 Swal from "sweetalert2";
export default function Home() {
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
const isAdmin = user.role_id === 1;
const [loading, setLoading] = useState(true);
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[]>([]);
useEffect(() => {
const fetchAll = async () => {
setLoading(true);
const [workshopsData, videosData, currentUserData] = await Promise.all([
getWorkshops(),
getVideos(),
getCurrentUser(),
]);
setWorkshops(workshopsData as Workshop[]);
setVideos(videosData as Video[]);
setCurrentUserData(currentUserData.data);
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}`),
]);
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);
/* Inscrever num workshop */
async function inscrever(workshopId: number) {
const response = await fetch(`http://127.0.0.1:8000/api/inscrever/${workshopId}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setMessageSuccess(data.message);
Swal.fire({
title: data.message,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
/* Cancelar inscrição num workshop */
async function cancelarInscricao(workshopId: number) {
const response = await fetch(`http://127.0.0.1:8000/api/cancelar-inscricao/${workshopId}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
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();
const formElement = event.currentTarget;
const formData = new FormData(formElement);
const name = formData.get("name");
const email = formData.get("email");
const subject = formData.get("subject");
const message = formData.get("message");
const payload = {
name: name,
email: email,
subject: subject,
message: message,
};
setSending(true);
const response = await fetch("http://127.0.0.1:8000/api/contact", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok) {
setError(null);
setSending(false);
setMessageSuccess(data.message);
setTimeout(() => {
setMessageSuccess("");
}, 3000)
formElement.reset();
} else {
setError(data as ApiErrorResponse);
setSending(false);
setMessageSuccess("");
}
}
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 a página...</span>
</div>
)
}
return (
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
<div className=" ps-0">
<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>
<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}%`} />
<div className="row mt-4">
{nextVideos.length > 0 ? nextVideos.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} />
<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>
</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>}
</div>
</div>
<div className="ms-0 ps-0 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="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className="position-relative">
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div className="px-3">
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
</div>
</div>
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
<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' }}>
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>
)
) : null}
</div>
</div>
</div>
)
) : nextWorkshops.length === 0 ? (
<div className="col-12 text-center text-sm-start px-0">
<span className="text-black text-muted fs-5 ms-sm-2 ps-sm-1">Sem workshops agendados</span>
</div>
) : null}
</div>
</div>
<div className="row">
<div className="col-12 col-lg-8 mt-5 px-3">
<form onSubmit={sendMail} className="pt-2">
<div className="row g-3 bg-white p-4 rounded-3 mt-3">
<div className="text-center text-sm-start mb-4">
<h2 className={styles.subtitle}>Formulário de contacto</h2>
<span>Preencha o formulário abaixo para entrar em contacto connosco</span>
</div>
<div className="col-12 col-md-6 text-start mt-0">
<label className={styles.label} htmlFor="name">Nome</label>
<input type="text" className="form-control py-2 border" placeholder="Nome completo" id="name" name="name" />
</div>
<div className="col-12 col-md-6 text-start mt-0">
<label className={styles.label} htmlFor="email">E-mail</label>
<input type="email" placeholder="E-mail" className="form-control py-2 border" id="email" name="email" />
</div>
<div className="col-12 text-start">
<label className={styles.label} htmlFor="subject">Assunto</label>
<select className="form-select py-2 border" id="subject" name="subject" defaultValue="">
<option value="" disabled>Assunto</option>
<option value="Suporte Técnico" >Suporte Técnico</option>
<option value="Sugestão">Sugestão</option>
<option value="Dúvida">Dúvida</option>
<option value="Outro">Outro</option>
</select>
</div>
<div className="col-12 text-start">
<label className={styles.label} htmlFor="message">Mensagem</label>
<textarea rows={5} className="form-control border py-2" id="message" name="message" placeholder="Escreva aqui a sua mensagem..." />
</div>
{error && (
<div className="col-12 text-center">
<span className="text-danger">{error.message}</span>
</div>
)}
{messageSuccess && (
<div className="col-12 text-center ">
<span className="text-success">{messageSuccess}</span>
</div>
)}
<div className="col-12 text-center text-sm-end">
<button type="submit" className={`${styles.submitButton}`} disabled={sending}>{sending ? (<><span>A enviar...</span> <CgSpinner className={`${styles.animateSpin} mb-1`} size={20} /></>) : "Submeter"}</button>
</div>
</div>
</form>
</div>
{!isAdmin ? (
<div className="col-12 col-lg-4 mt-lg-5 px-0 ps-lg-4">
<div className="h-100 pt-4">
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-3 text-white" >Vídeos assistidos</span>
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>0/{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
</div>
<div className="d-flex flex-column">
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>Workshops Inscrito</span>
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === currentUserData?.id)).length}</span>
</div>
</div>
</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

@@ -0,0 +1,246 @@
.container {
width: 100%;
max-width: 1400px;
align-self: start;
}
.title {
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.subtitle {
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
.link {
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
color: var(--text-primary-color);
transition: all .3s ease;
&:hover {
color: var(--primary-color-contrast);
}
}
.dateWorkshop,
.timeWorkshop {
background-color: var(--bg-white);
font-size: var(--size-font-small);
font-weight: 700;
border-radius: var(--border-radius-button);
padding: 5px 10px;
width: fit-content;
}
.dateWorkshopMobile,
.timeWorkshopMobile {
font-size: var(--size-font-small);
font-weight: 700;
}
.boxWorkshop {
height: fit-content;
/* max-height: 400px; */
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.titleWorkshop {
color: var(--text-primary-color);
font-size: var(--size-font-text);
font-weight: 500;
}
.thumbnailWorkshop {
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;
}
.linkWorkshop{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-grey);
color: var(--text-black);
transition: all .3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}
.btncancelarInscricao{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
transition: all .3s ease;
&:hover{
background-color: var(--bg-primary-color);
color: var(--text-white);
}
}
.btnInscrever{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--tertiary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity);
color: var(--text-tertiary-color);
}
}
.icon {
color: var(--text-primary-color);
}
.progressBar :global(.progress-bar) {
position: relative;
overflow: visible;
color: var(--text-primary-color);
font-weight: 800;
background-color: var(--bg-primary-color);
}
.iconEdit{
color: var(--text-white);
background-color: var(--bg-primary-color);
font-size: 2.3rem;
font-weight: 500;
transition: all 0.3s ease;
z-index: 1000;
position: absolute;
top: 10px;
right: 10px;
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.linkVideo {
display: block;
width: 100%;
border-radius: var(--border-radius);
transition: all 0.3s ease;
background-color: var(--bg-white);
}
.boxVideo {
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.boxVideo::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
}
.boxVideoInfo{
z-index: 1000;
position: relative;
max-width: 100%;
color: var(--text-white);
}
.titleVideo {
color: var(--text-white);
font-size: var(--size-font-text);
font-weight: 700;
}
.descriptionVideo {
color: var(--text-white);
font-size: var(--size-font-small);
font-weight: 500;
}
.videoThumbnail {
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
}
.formContact{
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.label{
color: var(--text-primary-color);
font-size: var(--size-font-small);
font-weight: 500;
}
.submitButton{
background-color: var(--primary-color);
color: var(--text-white);
border-radius: var(--border-radius-button);
border: none;
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--primary-color) !important;
}
}
.userVideosWatched{
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
}
.animateSpin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import type { ApiErrorResponse, User, Video, Workshop } from "../../../types";
import styles from "./styles.module.css";
import { LuCheck, LuKeyRound, LuPencil, LuX } from "react-icons/lu";
import { useGetWorkshops } from "../../../hooks/useGetWorkshops";
export default function Profile() {
const [user, setUser] = useState<User | null>(JSON.parse(localStorage.getItem("user") as unknown as string));
const [error, setError] = useState<ApiErrorResponse | null>(null);
const isAdmin = user?.role_id === 1;
/* const [loading, setLoading] = useState(true); */
const [formEdit, setFormEdit] = useState<boolean>(false);
const [formEditPassword, setFormEditPassword] = useState<boolean>(false);
const [messageUpdateUser, setMessageUpdateUser] = useState<string>("");
const [messageUpdatePassword, setMessageUpdatePassword] = useState<string>("");
const { getWorkshops } = useGetWorkshops();
const [workshops, setWorkshops] = useState<Workshop[]>([]);
useEffect(() => {
const fetchAll = async () => {
const [workshopsData] = await Promise.all([
getWorkshops(),
]);
setWorkshops(workshopsData as Workshop[]);
};
fetchAll();
}, []);
async function updatePassword(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const user_id = user?.id;
const passwordAtual = formData.get("passwordAtual");
const novaPassword = formData.get("novaPassword");
const confirmarPassword = formData.get("confirmarPassword");
if (novaPassword !== confirmarPassword) {
setError({
message: "As passwords não coincidem",
data: null,
errors: {},
});
return;
}
const payload = {
user_id: user_id,
passwordAtual: passwordAtual,
novaPassword: novaPassword,
confirmarPassword: confirmarPassword,
};
const response = await fetch(`http://127.0.0.1:8000/api/profile/${user_id}`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok) {
setMessageUpdatePassword("Palavra-passe atualizada com sucesso");
setTimeout(() => {
setMessageUpdatePassword("");
}, 3000)
setFormEditPassword(false);
setError(null);
} else {
setMessageUpdatePassword("");
setError(data as ApiErrorResponse);
}
}
async function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const user_id = user?.id;
const name = formData.get("name");
const email = formData.get("email");
const payload = {
user_id: user_id,
name: name,
email: email,
};
const response = await fetch(`http://127.0.0.1:8000/api/profile/${user_id}`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok) {
setUser(data.data);
setFormEdit(false);
setMessageUpdateUser(data.message as string);
setTimeout(() => {
setMessageUpdateUser("");
}, 3000)
setError(null);
} else {
setMessageUpdateUser("");
setError(data as ApiErrorResponse);
}
}
/* { user ? {
} : {
}
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar os seus dados...</span>
</div>
)
} */
return (
<div className="container">
<span className={styles.title}>Os meus dados</span>
<div>
<div className="my-3">
<div className="row mt-5 justify-content-between">
<div className="col-12 col-lg-8 p-3 rounded-3">
<div className={`${styles.userCard} p-4 bg-white h-100`}>
<div className="row justify-content-between">
<div className="col-12 d-flex text-start mt-xl-0 justify-content-between flex-grow-1 mb-3">
<div className="d-flex flex-column">
<span className="fw-bold fs-4 mt-1">{user?.name}</span>
<span className="fs-5 flex-grow-1">{user?.email}</span>
</div>
</div>
<div className="col-12 d-flex flex-column flex-md-row gap-0 gap-md-2 text-end align-items-start align-items-md-end mt-md-0 mx-auto mx-md-0 flex-wrap">
<span className="d-flex justify-content-center flex-wrap flex-sm-nowrap border border-secondary-subtle py-2 px-2 rounded-3 fs-6 fw-medium py-0" style={{ maxWidth: "fit-content", height: "fit-content" }}>Membro desde: <b className="fw-bold fs-6">{new Date(user?.created_at as string).toLocaleDateString('pt-PT')}</b></span>
<div className="d-flex flex-column flex-sm-row flex-grow-1 justify-content-evenly justify-content-md-end flex-wrap mt-3 mx-auto">
<a className={`${styles.updateButton} text-decoration-none text-primary text-center px-3`} onClick={() => { setFormEdit(true); setMessageUpdateUser(""); }}> Editar <LuPencil className={`${styles.icon} mb-1 text-primary`} /></a>
<a className={`${styles.passwordButton} text-decoration-none text-danger px-1 px-md-3
`} onClick={() => { setFormEditPassword(true); setMessageUpdateUser(""); }}> Alterar password <LuKeyRound className={`${styles.icon} mb-1 text-danger`} /></a>
</div>
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-4 p-3">
{isAdmin ? (
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-primary-color-opacity)" }}>Vídeos ativos</span>
<span className="fs-2 fw-bold text-white">{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-6" style={{ color: "var(--bg-grey)" }}>Workshops agendados</span>
<span className="fs-2 fw-bold text-white">{workshops.filter((workshop: Workshop) => workshop.status === "pending").length}</span>
</div>
</div>
) : (
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-primary-color-opacity)" }}>Vídeos assistidos</span>
<span className="fs-2 fw-bold text-white">0/{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-6" style={{ color: "var(--bg-grey)" }}>Workshops inscrito</span>
<span className="fs-2 fw-bold text-white">{workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === user?.id)).length}</span>
</div>
</div>
)}
</div>
</div>
</div>
</div>
{messageUpdateUser || messageUpdatePassword ? (
<div className="">
<div className="alert alert-success mt-4">
{messageUpdateUser || messageUpdatePassword}
</div>
</div>
) : null}
{formEdit ? (
<div className="my-3">
<span className={styles.title}>Editar dados</span>
<form method="patch" onSubmit={update} id="formEditUser">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label>
<input type="text" className="form-control py-2" id="name" name="name" defaultValue={user?.name} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="email">Email</label>
<input type="email" className="form-control py-2" id="email" name="email" defaultValue={user?.email} />
</div>
</div>
{error?.errors ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{error.errors.message || error.message}</p>
</div>
</div>
) : null}
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center flex-wrap" >
<button type="button" className={`${styles.closeFormButton}`} onClick={() => setFormEdit(false)}> Cancelar <LuX className={`${styles.icon} mb-1`} /></button>
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
</div>
</form>
</div>
) : formEditPassword ? (
<div className="my-3">
<span className={styles.title}>Alterar password</span>
<form method="patch" onSubmit={updatePassword} id="formEditPassword">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<div className="col-12 col-lg-4 text-start">
<label className="form-label fw-bold" htmlFor="passwordAtual">Password atual</label>
<input type="password" className="form-control py-2" id="passwordAtual" name="passwordAtual" />
</div>
<div className="col-12 col-sm-6 col-lg-4 text-start">
<label className="form-label fw-bold" htmlFor="novaPassword">Nova password</label>
<input type="password" className="form-control py-2" id="novaPassword" name="novaPassword" />
</div>
<div className="col-12 col-sm-6 col-lg-4 text-start">
<label className="form-label fw-bold" htmlFor="confirmarPassword">Confirmar password</label>
<input type="password" className="form-control py-2" id="confirmarPassword" name="confirmarPassword" />
</div>
</div>
{error?.errors ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{error.errors.message || error.message}</p>
</div>
</div>
) : null}
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="button" className={`${styles.closeFormButton}`} onClick={() => setFormEditPassword(false)}> Cancelar <LuX className={`${styles.icon} mb-1`} /></button>
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
</div>
</form>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,105 @@
.container{
align-self: start;
width: 100%;
max-width: 1400px;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.userCard{
border-radius: var(--border-radius);
background-color: var(--bg-white);
}
.updateButton{
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity) !important;
}
}
.passwordButton{
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity) !important;
}
}
.icon{
transition: all 0.3s ease;
}
.userVideosWatched{
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
}
.closeFormButton{
background-color: var(--bg-primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color) !important;
.icon{
color: var(--text-primary-color) !important;
}
}
}
.submitFormButton{
background-color: var(--tertiary-color);
color: var(--text-white) !important;
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bs-primary-bg-subtle);
color: var(--tertiary-color) !important;
.icon{
color: var(--tertiary-color) !important;
}
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import type { ApiErrorResponse, User, Video, Workshop } from "../../../types";
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";
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 [searchParams] = useSearchParams();
const query = (searchParams.get("q") ?? "").trim();
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
const isAdmin = user.role_id === 1;
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}`),
]);
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">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar os resultados da sua pesquisa...</span>
</div>
);
}
return (
<div className={styles.container}>
<h1 className={`${styles.subtitle} mb-4`}>Resultados da pesquisa: "{query}"</h1>
{videos.length > 0 ? (
<div className="row g-3 p-0">
<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">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<img className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
<h2 className={`${styles.titleVideo} mb-1`}>{video.title}</h2>
<span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span>
</div>
</div>
</Link>
</div>
))}
</div>
) : null}
{workshops.length > 0 ? (
<div className="row g-3 p-0 mt-3">
<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={`${styles.boxWorkshop} text-start pb-3`}>
<div className="position-relative">
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div className="px-3">
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
</div>
</div>
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center mt-3 py-2 px-5 mx-auto text-decoration-none`}> Detalhes </Link>
</div>
</div>
))}
</div>
) : null}
{videos.length === 0 && workshops.length === 0 ? (
<div className="mt-5" role="alert">
<span className="text-muted">Não foram encontrados resultados na sua pesquisa.</span>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,157 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.subtitle{
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
/* Vídeos */
.iconEdit{
color: var(--text-white);
background-color: var(--bg-primary-color);
font-size: 2.3rem;
font-weight: 500;
transition: all 0.3s ease;
z-index: 1000;
position: absolute;
top: 10px;
right: 10px;
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.linkVideo {
display: block;
width: 100%;
border-radius: var(--border-radius);
transition: all 0.3s ease;
background-color: var(--bg-white);
}
.boxVideo {
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.boxVideo::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
}
.boxVideoInfo{
z-index: 1000;
position: relative;
max-width: 100%;
}
.titleVideo {
color: var(--text-white);
font-size: var(--size-font-text);
font-weight: 700;
}
.descriptionVideo {
color: var(--text-white);
font-size: var(--size-font-small);
font-weight: 500;
}
.videoThumbnail {
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
}
/* Workshops */
.linkWorkshop{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-primary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
background-color: var(--neutral-color);
color: var(--text-white);
}
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-white);
font-size: var(--size-font-small);
font-weight: 700;
border-radius: var(--border-radius-button);
padding: 5px 10px;
width: fit-content;
}
.dateWorkshopMobile, .timeWorkshopMobile{
font-size: var(--size-font-small);
font-weight: 700;
}
.boxWorkshop{
height: fit-content;
max-height: 400px;
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.titleWorkshop{
color: var(--text-primary-color);
font-size: var(--size-font-text);
font-weight: 500;
}
.thumbnailWorkshop{
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;
}
.icon{
color: var(--text-primary-color);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,27 @@
.mainContainer{
display: flex;
width: 100%;
height: 100vh;
}
.contentArea {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
.main{
flex: 1;
background: var(--bg-grey);
overflow: auto;
padding: 20px;
justify-items: center;
}
input:focus, input:active{
border: none;
outline: none;
}

View File

@@ -0,0 +1,37 @@
import { Link, 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";
export default function ProtectedLayout() {
const navigate = useNavigate();
const token = localStorage.getItem("token");
if (!token) {
navigate("/login");
}
useEffect(() => {
if (!token) {
navigate("/login");
}
}, [token]);
return (
<>
<div className={styles.mainContainer}>
<Sidebar />
<section className={styles.contentArea}>
<Header />
<main className={styles.main}>
<Outlet />
</main>
{/* <Footer /> */}
</section>
</div>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { useNavigate } from "react-router";
import { useEffect } from "react";
export default function Home() {
return (
<div>
<h1>Home</h1>
</div>
)
}

View File

@@ -0,0 +1,26 @@
.mainContainer{
display: flex;
width: 100%;
height: 100vh;
}
.contentArea {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
.main{
flex: 1;
background: var(--bg-grey);
overflow: auto;
padding: 20px;
justify-items: center;
}
input:focus, input:active{
border: none;
outline: none;
}

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from "react";
import type { ApiErrorResponse, Category, Video } from "../../../types";
import { Link } from "react-router";
import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
import { LuPlus } from "react-icons/lu";
import { LuPencil } from "react-icons/lu";
export default function Videos() {
const [loadingTimeout, setLoadingTimeout] = useState(false);
const [loading, setLoading] = useState(true);
const [videos, setVideos] = useState<Video[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all");
useEffect(() => {
getCategories();
}, []);
async function getCategories() {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setCategories(data.data);
setError(null);
} else {
setCategories([]);
setError(data as ApiErrorResponse);
}
}
useEffect(() => {
if(!loading) return;
const timer = setTimeout(() => {
setLoadingTimeout(true);
}, 20000);
return () => clearTimeout(timer);
}, [loading]);
useEffect(() => {
index();
}, []);
async function index() {
setLoading(true);
try {
const response = await fetch("http://127.0.0.1:8000/api/videos", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setVideos(data.data as Video[]);
setError(null);
} else {
setVideos([]);
setError(data as ApiErrorResponse);
}
}
catch (error) {
setVideos([]);
setError(error as ApiErrorResponse);
} finally {
setLoading(false);
}
}
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
);
});
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 os vídeos...</span>
</div>)
}
if(videos.length === 0) {
return (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span>Nenhum vídeo encontrado</span>
<Link to="/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" />Adicionar vídeo</Link>
</div>
)
} else {
return (
<div className={`${styles.container} p-4 p-lg-0`}>
<h1 className={styles.title}>Videos</h1>
<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>
<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>
<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>
</div>
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center">
<Link to="/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">
{filteredVideos.map((video) => (
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2 position-relative">
<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} />
<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
">{video.description}</span>
</div>
</a>
</div>
</div>
))}
{filteredVideos.length === 0 && (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum vídeo encontrado com o filtro selecionado</span>
</div>
)}
</div>
</div>
</div>
)
}
}

View File

@@ -0,0 +1,84 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.btnAdicionarVideo{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.iconEdit{
color: var(--text-white);
background-color: var(--bg-primary-color);
font-size: 2.3rem;
font-weight: 500;
transition: all 0.3s ease;
z-index: 1000;
position: absolute;
top: 30px;
right: 30px;
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.linkVideo{
display: block;
width: 100%;
border-radius: var(--border-radius);
transition: all 0.3s ease;
background-color: var(--bg-white);
}
.boxVideo{
height: 200px;
}
.titleVideo{
color: var(--text-black);
font-size: var(--size-font-text);
font-weight: 500;
}
.thumbnail{
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;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import type { ApiErrorResponse, Workshop } from "../../../../types";
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";
export default function Workshops() {
const [workshops, setWorkshops] = useState<Workshop[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [loading, setLoading] = useState(true);
const [selectedWorkshopStatus, setSelectedWorkshopStatus] = useState<string>("all");
useEffect(() => {
getWorkshops();
}, []);
async function getWorkshops() {
setLoading(true);
const response = await fetch("http://127.0.0.1:8000/api/workshops", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
if(response.ok) {
response.json().then((data) => {
setWorkshops(data.data as Workshop[]);
setError(null);
setLoading(false);
});
} else {
response.json().then((data) => {
setError(data as ApiErrorResponse);
setLoading(false);
});
}
}
const filteredWorkshops = workshops.filter((workshop) => {
if (selectedWorkshopStatus === "all") return true;
if (selectedWorkshopStatus === "active") return true;
if (selectedWorkshopStatus === "inactive") return 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 os workshops...</span>
</div>
)
}
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>
<Link to="/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}>Workshops</h1>
<div className="row py-3 g-4">
<div className="col-12 col-sm-6 text-start px-2 ">
<label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label>
<select className="form-control select-filter" name="filter" id="filter" value={selectedWorkshopStatus} onChange={(e) => setSelectedWorkshopStatus(e.target.value)}>
<option key="all" value="all">Todos</option>
<option key="active" value="active">Ativos</option>
<option key="inactive" value="inactive">Inativos</option>
</select>
</div>
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center px-0">
<Link to="/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
</div>
</div>
<div className="row py-3 g-4">
{filteredWorkshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className="position-relative">
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div className="px-3">
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
</div>
</div>
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center mt-3 py-2 px-5 mx-auto text-decoration-none`}> Detalhes </Link>
</div>
</div>
))}
{filteredWorkshops.length === 0 && (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum workshop encontrado com o filtro selecionado</span>
</div>
)}
</div>
</div>
)
}
}

View File

@@ -0,0 +1,113 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.btnAdicionarWorkshop{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.iconEdit{
color: var(--text-white);
background-color: var(--bg-primary-color);
font-size: 2.3rem;
font-weight: 500;
transition: all 0.3s ease;
z-index: 1000;
position: absolute;
top: 30px;
right: 30px;
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.linkWorkshop{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-primary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
background-color: var(--neutral-color);
color: var(--text-white);
}
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-white);
font-size: var(--size-font-small);
font-weight: 700;
border-radius: var(--border-radius-button);
padding: 5px 10px;
width: fit-content;
}
.dateWorkshopMobile, .timeWorkshopMobile{
font-size: var(--size-font-small);
font-weight: 700;
}
.boxWorkshop{
height: fit-content;
max-height: 400px;
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.titleWorkshop{
color: var(--text-primary-color);
font-size: var(--size-font-text);
font-weight: 500;
}
.thumbnailWorkshop{
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;
}
.icon{
color: var(--text-primary-color);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,106 @@
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";
export default function Video() {
const { id } = useParams();
const [video, setVideo] = useState<Video | null>(null);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [loading, setLoading] = useState(true);
const [playerReady, setPlayerReady] = useState(false);
useEffect(() => {
getVideo();
}, [id]);
useEffect(() => {
if (!video) return;
const timer = setTimeout(() => {
setPlayerReady(true);
}, 1000); // espera 1 segundo após o video carregar
return () => clearTimeout(timer);
}, [video]);
async function getVideo() {
setLoading(true);
try {
const response = await fetch(`http://127.0.0.1:8000/api/edit-video/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setVideo(data.data);
} else {
setVideo(null);
setError(data as ApiErrorResponse);
}
} catch {
console.error(error);
} 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>
)
}
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 ? (
<div className="my-3 d-flex flex-column gap-2">
<span className={`${styles.title}`}>{video.title}</span>
<div style={{ maxWidth: "1000px", margin: "0 auto", display: playerReady ? 'block' : 'none' }}>
<Plyr
source={{
type: "video",
sources: [{ src: `http://127.0.0.1:8000${video.url}`, type: "video/mp4" }],
}}
/>
</div>
<small className="text-start"><b>Publicado a: </b>{video.created_at}</small>
<p className="text-start mt-2">{video.description}</p>
</div>
) : (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span className="alert alert-danger">{error?.message}</span>
</div>
)}
</div>
)}
</>
)
}

View File

@@ -0,0 +1,31 @@
.container{
align-self: start;
max-width: 1400px !important;
width: 100% !important;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useState } from "react";
import type { ApiErrorResponse, Category, Video } from "../../../types";
import { Link } from "react-router";
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 { useGetVideos } from "../../../hooks/useGetVideos";
import { useDebounce } from "../../../hooks/useDebounce";
export default function Videos() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiErrorResponse | null>(null);
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);
async function getRole() {
const response = await fetch("http://127.0.0.1:8000/api/me", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
setIsAdmin(data.data.role_id === 1);
} else {
setIsAdmin(false);
setError(data as ApiErrorResponse);
}
}
async function getCategories() {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
if (response.ok) {
const data = await response.json();
setCategories(data.data as Category[]);
setError(null);
return data.data as Category[];
} else {
const data = await response.json();
setCategories([]);
setError(data as ApiErrorResponse);
}
return [];
}
useEffect(() => {
setSearchCompleted(false);
const fetchAll = async () => {
getRole();
try {
const [videosData] = await Promise.all([
getVideos(debouncedSearch),
getCategories(),
]);
if (Array.isArray(videosData)) {
setVideos(videosData);
} 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) {
setVideos([]);
setError(e as ApiErrorResponse);
}
finally {
setLoading(false);
setSearchCompleted(true);
}
};
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
);
});
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ídeos...</span>
</div>)
}
return (
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
<h1 className={`${styles.title} mt-1`}>Videos</h1>
{error && (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span className="text-muted fs-5">{error.message}</span>
</div>
)}
<div className="row py-3 g-4 justify-content-between">
<div className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2 ">
<Form.Control type="text" placeholder="Pesquisar vídeos..."
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">
<Dropdown className="flex-grow-1" onSelect={(value) => {
if (value) {
setSelectedCategoryId(value);
}
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100">
<LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' : selectedCategoryId === 'active' ? 'Ativos' : 'Inativos'}
</Dropdown.Toggle>
<Dropdown.Menu style={{ zIndex: 4000 }} className="text-center w-100">
<Dropdown.Item eventKey="all" active={selectedCategoryId === 'all'}>Todos</Dropdown.Item>
{isAdmin === true && (
<>
<Dropdown.Item eventKey="active" active={selectedCategoryId === 'active'}>Ativos</Dropdown.Item>
<Dropdown.Item eventKey="inactive" active={selectedCategoryId === 'inactive'}>Inativos</Dropdown.Item>
</>
)}
{categories.map((category) => (
<Dropdown.Item key={category.id} eventKey={String(category.id)} active={selectedCategoryId === String(category.id)}>{category.name}</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div>
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4">
<Link to="/admin/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" /> Adicionar vídeo</Link>
</div>
</div>
{/* <div className="row py-3 g-4">
<div className="col-12 col-sm-6 text-start">
<label htmlFor="filter" className="form-label fw-bold text-start">Filtrar vídeos</label>
<div className="position-relative">
<select className="form-control select-filter" name="filter" id="filter" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value)}>
<option key="all" value="all">Todos</option>
{isAdmin === true && (
<>
<option key="active" value="active">Ativos</option>
<option key="inactive" value="inactive">Inativos</option>
</>
)}
{categories.map((category) => (
<option key={category.id} value={String(category.id)}>{category.name}</option>
))}
</select>
<span className="position-absolute top-50 end-0 translate-middle-y me-2"><LuChevronDown className="mb-1" /></span>
</div>
<span className="form-text text-muted"> Selecione um filtro para filtrar os vídeos</span>
</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 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum vídeo encontrado</span>
</div>
) : searchCompleted && videos.length > 0 ? (
<>
{filteredVideos.map((video) => (
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<img className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
<h2 className={`${styles.titleVideo} d-flex text-wrap mb-1`}>{video.title}</h2>
{/* <span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span> */}
</div>
</div>
</Link>
</div>
))}
</>
) : (
<div className="col-12 text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)}
{ videos.length > 0 && filteredVideos.length === 0 && (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum vídeo encontrado com o filtro selecionado</span>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.btnAdicionarVideo{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.iconEdit{
color: var(--text-white);
background-color: var(--bg-primary-color);
font-size: 2.3rem;
font-weight: 500;
transition: all 0.3s ease;
z-index: 1000;
position: absolute;
top: 10px;
right: 10px;
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.linkVideo {
display: block;
width: 100%;
border-radius: var(--border-radius);
transition: all 0.3s ease;
background-color: var(--bg-white);
}
.boxVideo {
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.boxVideo::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
}
.boxVideoInfo{
z-index: 1000;
position: relative;
max-width: 100%;
}
.titleVideo {
color: var(--text-white);
font-size: var(--size-font-text);
font-weight: 700;
}
.descriptionVideo {
color: var(--text-white);
font-size: var(--size-font-small);
font-weight: 500;
}
.videoThumbnail {
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,210 @@
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 "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";
export default function Workshop() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string) as User;
const isAdmin = user.role_id === 1;
const { id } = useParams();
const [loading, setLoading] = useState(true);
const [workshop, setWorkshop] = useState<Workshop | null>(null);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const { getCurrentUser } = useGetCurrentUser();
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
const { preloadImages } = usePreloadImages();
useEffect(() => {
const fetchAll = async () => {
setLoading(true);
const [workshopData, currentUserData] = await Promise.all([
getWorkshop(),
getCurrentUser(),
]);
setWorkshop(workshopData as Workshop); // workshopData já é o Workshop
setCurrentUserData(currentUserData.data);
if (workshopData) {
await preloadImages([
`http://127.0.0.1:8000/storage/${(workshopData as Workshop).image}`
]);
}
setLoading(false);
};
fetchAll();
}, []);
async function getWorkshop() {
try {
const response = await fetch(`http://127.0.0.1:8000/api/workshop/${id}`, {
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 Workshop; // ← um único Workshop, não array
} else {
setError(data as ApiErrorResponse);
return null;
}
} catch (err) {
setError({ message: "Erro de ligação" } as ApiErrorResponse);
return null;
}
}
/* Inscrever num workshop */
async function inscrever(workshopId: number) {
const response = await fetch(`http://127.0.0.1:8000/api/inscrever/${workshopId}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
const workshop = await getWorkshop();
setWorkshop(workshop as Workshop);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
/* Cancelar inscrição num workshop */
async function cancelarInscricao(workshopId: number) {
const response = await fetch(`http://127.0.0.1:8000/api/cancelar-inscricao/${workshopId}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
const workshop = await getWorkshop();
setWorkshop(workshop as Workshop);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
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 dados do workshop...</span>
</div>
)
}
return (
<div className={`${styles.container} p-3 p-xl-0`}>
<div className={"text-start mb-3"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}>
<Link className={`${styles.link} text-decoration-none`} to="/workshops">
<LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Workshops</span>
</Link>
</button>
</div>
{error && (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span className="text-muted fs-5">{error.message}</span>
</div>
)}
{ 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-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="mx-auto ms-sm-0">
{!isAdmin ? (
currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
Anular inscrição
</button>
) : (
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
Inscrever-me
</button>
)
) : null}
</div>
</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>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,196 @@
.container{
width: 100%;
max-width: 1400px;
align-self: center;
}
.LinkIcon, .linkText{
color: var(--text-black);
}
.button:hover .LinkIcon, .button:hover .linkText{
color: var(--primary-color);
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.subtitle{
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
.thumbnail{
width: 300px;
height: 250px;
object-fit: cover;
border-radius: var(--border-radius);
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-primary-color-opacity);
color: var(--text-black);
font-size: 18px;
font-weight: 700;
border-radius: var(--border-radius-button);
padding: 5px 8px;
width: 180px;
}
.contagemUtilizadoresInscritos{
color: var(--text-black);
padding: 3px 9px;
font-weight: 800;
}
.btnClose{
background-color: transparent;
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-subtitle);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
}
}
.iconCalendar,.iconClock,.iconClose{
color: var(--text-primary-color);
}
.iconWarning{
color: var(--neutral-color);
}
.updateButton{
background-color: var(--text-primary);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity) !important;
color: var(--text-tertiary-color) !important;
.icon{
color: var(--text-tertiary-color) !important;
}
}
}
.btncancelarInscricao{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-primary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}
.btnInscrever{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--tertiary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity);
color: var(--text-tertiary-color);
}
}
.deleteButton, .cancelButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
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;
color: var(--text-primary-color) !important;
.icon{
color: var(--text-primary-color) !important;
}
}
}
.activateButton{
background-color: var(--bs-success);
color: var(--text-white);
border: none;
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-success-color-opacity) !important;
color: var(--success-color) !important;
.icon{
color: var(--text-success-color) !important;
}
}
}
.btnInscritos{
color: var(--text-neutral-color);
border-radius: var(--border-radius-button);
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-white);
}
}
:global(.react-datepicker__header),
:global(.react-datepicker__header--time) {
display: none !important;
}
:global(.react-datepicker__time) {
width: 0px !important;
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,250 @@
import { useEffect, useState } from "react";
import type { User, Workshop } from "../../../types";
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 Swal from "sweetalert2";
import { Dropdown } from "react-bootstrap";
export default function Workshops() {
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
const isAdmin = user.role_id === 1;
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);
useEffect(() => {
const fetchAll = async () => {
setLoading(true);
const [workshopsData, currentUserData] = await Promise.all([
getWorkshops(),
getCurrentUser(),
]);
setWorkshops(workshopsData as Workshop[]);
setCurrentUserData(currentUserData?.data as User);
console.log("workshops response:", workshopsData);
await preloadImages([
...(workshopsData as Workshop[]).map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
]);
setLoading(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);
});
/* Inscrever num workshop */
async function inscrever(workshopId: number) {
const response = await fetch(`http://127.0.0.1:8000/api/inscrever/${workshopId}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
/* Cancelar inscrição num workshop */
async function cancelarInscricao(workshopId: number) {
const response = await fetch(`http://127.0.0.1:8000/api/cancelar-inscricao/${workshopId}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
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 workshops...</span>
</div>
)
}
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 ">
{/* <label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label> */}
<Dropdown onSelect={(value) => {
if (value) setSelectedWorkshopStatus(value);
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100 text-center" style={{ maxWidth: '205px' }}>
<LuSettings2 /> {selectedWorkshopStatus === 'all' ? 'Todos' : selectedWorkshopStatus === 'pending' ? 'Agendados' : selectedWorkshopStatus === 'inscrito' ? 'Inscrito' : selectedWorkshopStatus === 'realized' ? 'Realizados' : 'Cancelados'}
</Dropdown.Toggle>
<Dropdown.Menu className="text-center w-100" style={{ maxWidth: '205px' }}>
<Dropdown.Item eventKey="all" active={selectedWorkshopStatus === 'all'}>
Todos
</Dropdown.Item>
<Dropdown.Item eventKey="pending" active={selectedWorkshopStatus === 'pending'}>
Agendados
</Dropdown.Item>
{!isAdmin && (
<Dropdown.Item eventKey="inscrito" active={selectedWorkshopStatus === 'inscrito'}>
Inscrito
</Dropdown.Item>
)}
<Dropdown.Item eventKey="realized" active={selectedWorkshopStatus === 'realized'}>
Realizados
</Dropdown.Item>
<Dropdown.Item eventKey="canceled" active={selectedWorkshopStatus === 'canceled'}>
Cancelados
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
{isAdmin ? (
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center pe-sm-2 mt-sm-3" style={{ minHeight: '40px' }}>
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
</div>
) : null}
</div>
<div className="row py-3 g-4">
{filteredWorkshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className="position-relative">
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div className="px-3">
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
</div>
</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" ? (
<>
<span className="text-success fw-bold text-center py-2 mb-0">Workshop realizado</span>
{isAdmin ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : null}
</>
) : workshop.status === "canceled" ? (
<>
<span className="text-danger fw-bold text-center py-2 mb-0">Workshop cancelado</span>
{isAdmin ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : null}
</>
) : isAdmin ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : null}
</div>
</div>
</div>
))}
{selectedWorkshopStatus === "pending" && filteredWorkshops.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Sem workshops agendados</span>
</div>
) : filteredWorkshops.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum workshop encontrado com o filtro selecionado</span>
</div>
) : null}
</div>
</div>
)
}
}

View File

@@ -0,0 +1,144 @@
.container{
width: 100%;
max-width: 1400px;
align-self: start;
}
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.btnAdicionarWorkshop{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.iconEdit{
color: var(--text-white);
background-color: var(--bg-primary-color);
font-size: 2.3rem;
font-weight: 500;
transition: all 0.3s ease;
z-index: 1000;
position: absolute;
top: 30px;
right: 30px;
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover{
background-color: var(--neutral-color);
}
}
.linkWorkshop{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-grey);
color: var(--text-black);
transition: all .3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}
.btncancelarInscricao{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
transition: all .3s ease;
&:hover{
background-color: var(--bg-primary-color);
color: var(--text-white);
}
}
.btnInscrever{
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--tertiary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
background-color: var(--bg-tertiary-color-opacity);
color: var(--text-tertiary-color);
}
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-white);
font-size: var(--size-font-small);
font-weight: 700;
border-radius: var(--border-radius-button);
padding: 5px 10px;
width: fit-content;
}
.dateWorkshopMobile, .timeWorkshopMobile{
font-size: var(--size-font-small);
font-weight: 700;
}
.boxWorkshop{
height: fit-content;
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.titleWorkshop{
color: var(--text-primary-color);
font-size: var(--size-font-text);
font-weight: 500;
}
.thumbnailWorkshop{
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;
}
.icon{
color: var(--text-primary-color);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,10 @@
import { Outlet } from "react-router";
import styles from "./styles.module.css";
export default function PublicLayout() {
return (
<main className={styles.main}>
<Outlet />
</main>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect, useState, type FormEvent } from "react";
import { useNavigate } from "react-router";
import type { ApiErrorResponse, LoginResponse, User } from "../../../types";
import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
export default function Login() {
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [error, setError] = useState<ApiErrorResponse | null>(null);
const navigate = useNavigate();
const token = localStorage.getItem("token");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (token) navigate("/dashboard");
}, [token, navigate]);
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
if (!email.includes('@')) {
setError({ message: "Email inválido", data: null, errors: {} });
return;
}
if (password.length < 6) {
setError({ message: "Password deve ter pelo menos 6 caracteres", data: null, errors: {} });
return;
}
try {
const response = await fetch("http://127.0.0.1:8000/api/login", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({
email,
password
})
});
const data = (await response.json()) as LoginResponse;
if (response.ok) {
localStorage.setItem("token", data.access_token as string);
localStorage.setItem("user", JSON.stringify(data.user as User));
navigate("/dashboard");
} else {
setError(data as ApiErrorResponse);
setLoading(false);
}
} catch {
setError({ message: "Erro de autenticação, tente novamente.", data: null, errors: {} });
setLoading(false);
}
}
return (
<div >
<div className={styles.loginContainer}>
<img src="/src/assets/logo.png" alt="Logo" className={styles.loginLogo} />
<span className="mb-4">Insira os seus dados para aceder à sua conta.</span>
<form className={styles.loginForm} onSubmit={handleSignIn}>
<div className={`${styles.loginFormItem} text-start`}>
<label className={styles.loginFormLabel} htmlFor="email">Email</label>
<input
className={`p-2 border-0 ${styles.loginFormInput}`}
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className={`${styles.loginFormItem} text-start`}>
<label className={styles.loginFormLabel} htmlFor="password">Password</label>
<input
className={`p-2 border-0 ${styles.loginFormInput}`}
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<p className="text-danger">
{error && <p >{error.message}</p>}
</p>
<button type="submit" className={styles.btnLogin} disabled={loading}> {loading ? <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> : "Entrar"}</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
.loginPage{
width: 100%;
height: 100vh;
display: flex;
align-items: center;
}
.backgroundImage{
width: 100%;
}
.loginLogo{
width: 300px;
}
.loginContainer{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--bg-white);
padding: 40px;
width: 450px;
border-radius: var(--border-radius);
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
.loginTitle{
color: var(--text-black);
font-size: 32px;
font-weight: 800;
margin-bottom: 20px;
}
.loginForm{
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.loginFormItem{
display: flex;
flex-direction: column;
width: 100%;
}
.loginFormLabel{
color: var(--text-primary-contrast-color);
}
.loginFormInput{
background-color: var(--bg-grey);
color: var(--text-primary-contrast-color);
border-radius: var(--border-radius-input);
&:active, &:focus{
border: none;
outline: none;
}
}
.loginFormCheckbox{
border: 1px solid var(--border-primary-color) !important;
background-color: var(--bg-white) !important;
color: var(--primary-color) !important;
cursor: pointer;
&:checked{
background-color: var(--primary-color) !important;
}
}
.btnLogin{
background: var(--bg-gradient);
border: none;
border-radius: var(--border-radius-button);
color: var(--text-white);
font-size: 16px;
font-weight: 600;
padding: 10px 20px;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
box-shadow: var(--shadow-primary);
}
.btnLogin:hover{
opacity: 0.9;
}
@media (max-width: 500px) {
.loginContainer{
max-width: 300px;
padding: 20px;
}
.loginLogo{
width: 200px;
}
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,14 @@
import { useEffect } from "react";
import { useNavigate } from "react-router";
export default function Logout() {
const navigate = useNavigate();
useEffect(() => {
localStorage.removeItem("token");
navigate("/login", { replace: true }); /* Replace é para evitar que o usuário possa voltar para a página de logout */
}, [navigate]);
return null;
}

View File

@@ -0,0 +1,124 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router";
import type { RegisterResponse } from "../../../types";
export default function Register() {
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [password_confirmation, setPasswordConfirmation] = useState<string>("");
const [error, setError] = useState<string | undefined>("");
const navigate = useNavigate();
async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
if (name.length < 3) {
setError("Nome deve ter pelo menos 3 caracteres");
return;
}
if (!email.includes('@')) {
setError("Email inválido");
return;
}
if (password !== password_confirmation) {
setError("As senhas não coincidem");
return;
}
if (password.length < 6) {
setError("Password deve ter pelo menos 6 caracteres");
return;
}
try {
const response = await fetch("http://127.0.0.1:8000/api/register", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({
email,
password,
password_confirmation
})
});
const data = (await response.json()) as RegisterResponse;
console.log(data);
if (response.ok && typeof data.token === "string") {
localStorage.setItem("token", data.token);
navigate("/login");
} else {
setError(data.error);
}
} catch {
setError("Erro ao registar, tente novamente.");
}
}
return (
<div >
<div >
<h2>Registar</h2>
<form onSubmit={handleRegister}>
{error && <p >{error}</p>}
<div >
<label htmlFor="name">Nome</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div >
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div >
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div >
<label htmlFor="password_confirmation">Confirme a Password</label>
<input
id="password_confirmation"
type="password"
value={password_confirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
required
minLength={6}
/>
</div>
<button type="submit">Registar</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
.main{
flex: auto;
justify-content: center;
align-items: center;
display: flex;
background-image: url('/src/assets/background.png');
background-size: cover;
background-repeat: no-repeat;
background-position: top center;
background-attachment: fixed;
}
input:focus, input:active{
border: none;
outline: none;
}

View File

@@ -0,0 +1,74 @@
import { createBrowserRouter } from "react-router";
import ProtectedLayout from "./pages/private/_layout";
import PublicLayout from "./pages/public/_layout";
import Dashboard from "./pages/private/dashboard";
import Users from "./pages/private/admin/users";
import Login from "./pages/public/login";
import Register from "./pages/public/register";
import Workshops from "./pages/private/workshops";
import Contactos from "./pages/private/contactos";
import Videos from "./pages/private/videos";
import User from "./pages/private/admin/user/[id]";
import CreateUser from "./pages/private/admin/createUser";
import CreateVideo from "./pages/private/admin/createVideo";
import EditVideo from "./pages/private/admin/editVideo/[id]";
import Video from "./pages/private/video/[id]";
import Logout from "./pages/public/logout";
import CreateWorkshop from "./pages/private/admin/createWorkshop";
import Workshop from "./pages/private/workshop/[id]";
import Search from "./pages/private/search";
import Profile from "./pages/private/profile";
import AdminLayout from "./pages/private/admin/_layout";
import EditWorkshop from "./pages/private/admin/editWorkshop/[id]";
export const router = createBrowserRouter(
[
{
element: <ProtectedLayout />,
children: [
{path: "/dashboard", element: <Dashboard /> },
{path: "/workshops", element: <Workshops /> },
{path: "/workshop/:id", element: <Workshop /> },
{path: "/contactos", element: <Contactos /> },
{path: "/videos", element: <Videos /> },
{path: "/video/:id", element: <Video /> },
{path: "/search", element: <Search /> },
{path: "/profile", element: <Profile /> },
{path: "/workshop/:id", element: <Workshop /> },
{
element: <AdminLayout />,
children: [
{path: "/admin/users", element: <Users /> },
{path: "/admin/user/:id", element: <User /> },
{path: "/admin/create-user", element: <CreateUser /> },
{path: "/admin/create-workshop", element: <CreateWorkshop /> },
{path: "/admin/edit-workshop/:id", element: <EditWorkshop /> },
{path: "/admin/create-video", element: <CreateVideo /> },
{path: "/admin/edit-video/:id", element: <EditVideo /> },
]
}
]
},
{
element: <PublicLayout />,
children: [
{
path: "/",
element: <Login />,
},
{
path: "/login",
element: <Login />,
},
{
path: "/logout",
element: <Logout />,
},
]
}
]
);

View File

@@ -0,0 +1,113 @@
export type LoginResponse = {
access_token?: string;
expires_in?: number;
user?: User;
}
export type RegisterResponse = {
token?: string;
user?: User;
}
export type CreateUserResponse = {
message?: string;
data?: User;
}
export type ApiErrorResponse = {
message: string;
data: null;
errors: Record<string, string[]>;
}
export type User = {
id: number;
name: string;
email: string;
role_id: number;
password: string;
created_at: string;
updated_at: string;
allowed_access?: boolean;
pivot?: {
workshop_id: number;
user_id: number;
created_at: string;
updated_at: string;
};
}
export type UpdateUserResponse = {
message?: string;
data?: User;
}
export type GetUserResponse = {
data?: User;
message?: string;
}
export type CreateVideoResponse = {
message?: string;
data?: Video;
}
export type Video = {
id: number;
title: string;
description: string;
url: File | null;
thumbnail: File | null;
duration: string;
tags: string;
categories: Category[] | null;
created_at: string;
updated_at: string;
is_active: boolean;
}
export type UpdateVideoResponse = {
message?: string;
data?: Video;
}
export type GetVideoResponse = {
message?: string;
data?: Video;
}
export type CreateCategoryResponse = {
message?: string;
data?: Category;
}
export type Category = {
id: number;
name: string;
is_active: boolean;
}
export type Workshop = {
id: number;
status: "pending" | "realized" | "canceled";
title: string;
description: string;
image: string;
date: string;
time_start: string;
time_end: string;
is_active: boolean;
users: User[];
}
export type getWorkshop = {
message?: string;
data?: Workshop;
}
export type CreateWorkshopResponse = {
message?: string;
data?: Workshop;
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

Some files were not shown because too many files have changed in this diff Show More