feat: request optimizations

This commit is contained in:
Xavier Oliveira
2026-05-28 11:23:57 +01:00
parent 68f99798ce
commit 996d44f33d
38 changed files with 1258 additions and 1116 deletions

View File

@@ -3,17 +3,17 @@ import { LuUser, LuMenu, LuSearch, LuLogOut, LuUsers, LuMail, LuGraduationCap, L
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, Link } from "react-router"; import { useNavigate, Link } from "react-router";
import { Button, NavLink, Offcanvas } from "react-bootstrap"; import { Button, NavLink, Offcanvas } from "react-bootstrap";
import type { User, Workshop } from "../../types"; import type { Workshop } from "../../types";
import type { Video } from "../../types"; import type { Video } from "../../types";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import { useDebounce } from "../../hooks/useDebounce"; import { useDebounce } from "../../hooks/useDebounce";
import { useGetVideos } from "../../hooks/useGetVideos"; import { useGetVideos } from "../../hooks/useGetVideos";
import { useGetVideosLength } from "../../hooks/useGetVideosLength";
import { useGetVideosSearch } from "../../hooks/useGetVideosSearch"; import { useGetVideosSearch } from "../../hooks/useGetVideosSearch";
import { PiCheckCircleFill } from "react-icons/pi"; import { PiCheckCircleFill } from "react-icons/pi";
import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch"; import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton"; import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useGetCurrentUser } from "../../hooks/useGetCurrentUser";
export default function Header() { export default function Header() {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
@@ -23,31 +23,28 @@ export default function Header() {
const [searchCompleted, setSearchCompleted] = useState(false); const [searchCompleted, setSearchCompleted] = useState(false);
const [videosSearched, setVideosSearched] = useState<Video[]>([]); const [videosSearched, setVideosSearched] = useState<Video[]>([]);
const [workshopsSearched, setWorkshopsSearched] = useState<Workshop[]>([]); 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 debouncedSearch = useDebounce(search, 500);
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const { getVideos } = useGetVideos(); const { getVideos } = useGetVideos();
const { getVideosLength } = useGetVideosLength();
const { getVideosSearch } = useGetVideosSearch(); const { getVideosSearch } = useGetVideosSearch();
const { getWorkshopsSearch } = useGetWorkshopsSearch(); const { getWorkshopsSearch } = useGetWorkshopsSearch();
const [videosStats, setVideosStats] = useState({ const { getCurrentUser } = useGetCurrentUser();
videos: 0, const [role, setRole] = useState(0);
videosWatched: 0 const [videosWatched, setVideosWatched] = useState(0);
}); const [videosCount, setVideosCount] = useState(0);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (isAdmin) return; const fetchAll = async () => {
const videosWatched = localStorage.getItem("videosWatched");
const fetchProgressVideos = async () => { setVideosWatched(videosWatched ? parseInt(videosWatched) : 0);
const videosLengthData = await getVideosLength(); const videosCount = localStorage.getItem("videosCount");
if ("videos" in videosLengthData) { setVideosCount(videosCount ? parseInt(videosCount) : 0);
setVideosStats(videosLengthData as { videos: number, videosWatched: number }); const userData = await getCurrentUser();
} setRole(userData.data.role_id);
}; };
fetchProgressVideos(); fetchAll();
}, []); }, []);
const handleCloseMenu = () => setShowMenu(false); const handleCloseMenu = () => setShowMenu(false);
@@ -132,7 +129,13 @@ export default function Header() {
<Offcanvas.Header > <Offcanvas.Header >
<Offcanvas.Title className={"w-100 d-flex justify-content-between"}> <Offcanvas.Title className={"w-100 d-flex justify-content-between"}>
<Link className={`${styles.logoLink} justify-content-start`} to="/dashboard"> <Link className={`${styles.logoLink} justify-content-start`} to="/dashboard">
<img className={styles.logo} src="/src/assets/logo.png" alt="Logo" /> <img
className={styles.logo}
src="/src/assets/logo.png"
alt="Logo"
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
</Link> </Link>
<button type="button" className={`${styles.sidebarClose} align-items-center justify-content-center d-xl-none`} onClick={handleCloseMenu}> <LuPanelLeftClose size={24} /> </button> <button type="button" className={`${styles.sidebarClose} align-items-center justify-content-center d-xl-none`} onClick={handleCloseMenu}> <LuPanelLeftClose size={24} /> </button>
</Offcanvas.Title> </Offcanvas.Title>
@@ -150,7 +153,7 @@ export default function Header() {
<li className={`${styles.navItem} text-start`}> <li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/workshops"> <LuGraduationCap className="me-2" size={24} />Workshops</NavLink> <NavLink className={styles.navLink} href="/workshops"> <LuGraduationCap className="me-2" size={24} />Workshops</NavLink>
</li> </li>
{isAdmin && ( {role === 1 && (
<li className={`${styles.navItem} text-start`}> <li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/admin/users"> <LuUsers className="me-2" size={24} />Utilizadores</NavLink> <NavLink className={styles.navLink} href="/admin/users"> <LuUsers className="me-2" size={24} />Utilizadores</NavLink>
</li> </li>
@@ -161,10 +164,10 @@ export default function Header() {
</ul> </ul>
</nav> </nav>
{!isAdmin && videosStats.videos > 0 && ( {role !== 1 && videosCount > 0 && (
<div className="d-flex d-sm-none justify-content-center"> <div className="d-flex d-sm-none justify-content-center">
<span className="fw-semibold">Vídeos assistidos</span> <span className="fw-semibold">Vídeos assistidos</span>
<span className={`${styles.badge} badge align-content-center ms-3`}>{videosStats.videosWatched}/{videosStats.videos}</span> <span className={`${styles.badge} badge align-content-center ms-3`}>{videosWatched}/{videosCount}</span>
</div> </div>
)} )}
@@ -177,11 +180,11 @@ export default function Header() {
<nav className="navbar px-3"> <nav className="navbar px-3">
<div className={styles.headerRight}> <div className={styles.headerRight}>
{!isAdmin && videosStats.videos > 0 && ( {role !== 1 && videosCount > 0 && (
<> <>
<div className="d-none d-sm-flex align-items-center gap-1 text-muted"> <div className="d-none d-sm-flex align-items-center gap-1 text-muted">
<span className="fw-semibold">Vídeos assistidos</span> <span className="fw-semibold">Vídeos assistidos</span>
<span className={`${styles.badge} badge align-content-center`}>{videosStats.videosWatched}/{videosStats.videos}</span> <span className={`${styles.badge} badge align-content-center`}>{videosWatched}/{videosCount}</span>
</div> </div>
</> </>
)} )}

View File

@@ -61,7 +61,12 @@
.logo { .logo {
width: 180px; width: 180px;
aspect-ratio: 3 / 1;
height: auto; height: auto;
object-fit: contain;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
} }
.nav { .nav {

View File

@@ -1,17 +1,27 @@
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { Link, NavLink } from "react-router"; import { Link, NavLink } from "react-router";
import { useState } from "react"; import { useEffect, useState } from "react";
import { LuPanelLeftClose, LuPanelLeftOpen, LuTvMinimalPlay } from "react-icons/lu"; import { LuPanelLeftClose, LuPanelLeftOpen, LuTvMinimalPlay } from "react-icons/lu";
import { LuGraduationCap } from "react-icons/lu"; import { LuGraduationCap } from "react-icons/lu";
import { LuUsers } from "react-icons/lu"; import { LuUsers } from "react-icons/lu";
import { LuMail } from "react-icons/lu"; import { LuMail } from "react-icons/lu";
import { LuLayoutDashboard } from "react-icons/lu"; import { LuLayoutDashboard } from "react-icons/lu";
import { LuLogOut } from "react-icons/lu"; import { LuLogOut } from "react-icons/lu";
import { useGetCurrentUser } from "../../hooks/useGetCurrentUser";
export default function Sidebar() { export default function Sidebar() {
const { getCurrentUser } = useGetCurrentUser();
const [role, setRole] = useState(0);
const [sideMenu, setSideMenu] = useState(true); const [sideMenu, setSideMenu] = useState(true);
const user = JSON.parse(localStorage.getItem("user") as unknown as string); useEffect(() => {
const fetchCurrentUser = async () => {
const currentUser = await getCurrentUser();
setRole(currentUser.data.role_id);
};
fetchCurrentUser();
}, []);
return sideMenu ? ( return sideMenu ? (
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
@@ -24,7 +34,7 @@ export default function Sidebar() {
</div> </div>
<nav className={`${styles.nav}`}> <nav className={`${styles.nav}`}>
{user.role_id === 1 ? ( {role === 1 ? (
<ul className={styles.navList}> <ul className={styles.navList}>
<li className={`${styles.navItem} text-start`}> <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> <NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard"> <LuLayoutDashboard className="me-2" size={24} /> {sideMenu ? "Dashboard" : ""} </NavLink>

View File

@@ -1,4 +1,4 @@
import type { ApiErrorResponse, Video } from "../types"; import type { ApiErrorResponse, Category, Video } from "../types";
type GetVideosParams = { type GetVideosParams = {
page?: number; page?: number;
@@ -33,7 +33,9 @@ export function useGetVideos() {
if (response.ok) { if (response.ok) {
return { return {
videos: data.data as Video[], videos: data.data as Video[],
meta: data.meta meta: data.meta,
role: data.role,
categories: data.categories as Category[],
}; };
} else { } else {
return data as ApiErrorResponse; return data as ApiErrorResponse;

View File

@@ -30,7 +30,9 @@ export function useGetWorkshops() {
if (response.ok) { if (response.ok) {
return { return {
workshops: data.data as Workshop[], workshops: data.data as Workshop[],
meta: data.meta meta: data.meta,
userId: data.userId,
role: data.role,
}; };
} else { } else {
return (data as ApiErrorResponse); return (data as ApiErrorResponse);

View File

@@ -1,67 +1,43 @@
import { Outlet, useNavigate } from "react-router"; import { Outlet, useNavigate } from "react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ApiErrorResponse, User } from "../../../types"; import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser";
import { CgSpinner } from "react-icons/cg";
import styles from "./styles.module.css";
export default function AdminLayout() { export default function AdminLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const userRaw = localStorage.getItem("user"); const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(userRaw ? JSON.parse(userRaw) : null);
/* Verificação do role_id no frontend das páginas do admin */ /* Verificação do role_id no frontend das páginas do admin */
const [checkingRole, setCheckingRole] = useState<boolean>(true); const { getCurrentUser } = useGetCurrentUser();
const [error, setError] = useState<ApiErrorResponse | null>(null);
useEffect(() => { useEffect(() => {
getVerifyRole(); const fetchCurrentUser = async () => {
}, []); setLoading(true);
async function getVerifyRole() {
try { try {
const response = await fetch("http://127.0.0.1:8000/api/me", { const currentUser = await getCurrentUser();
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) { if (currentUser.data.role_id !== 1) {
setCheckingRole(false); navigate("/dashboard", { replace: true });
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; return;
} }
setLoading(false);
} catch (error) { } catch (error) {
setCheckingRole(false);
navigate("/dashboard");
return;
}
}
useEffect(() => {
if (!user || user.role_id !== 1) {
navigate("/dashboard", { replace: true }); navigate("/dashboard", { replace: true });
} }
}, [user, navigate]); };
fetchCurrentUser();
}, []);
if (!user || user.role_id !== 1) return null; 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`} />
</div>;
}
return <Outlet />;
return (
<Outlet />
);
/* Criado o layout para o admin, o proximo passo é aninhar este layout em routes.tsx no frontend */ /* Criado o layout para o admin, o proximo passo é aninhar este layout em routes.tsx no frontend */
} }

View File

@@ -12,13 +12,8 @@ export default function CreateUser() {
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>("");
const [password_confirmation, setPasswordConfirmation] = useState<string>(""); const [password_confirmation, setPasswordConfirmation] = useState<string>("");
const [role_id, setRoleId] = useState("2"); const [role_id, setRoleId] = useState("2");
const [checkingRole, setCheckingRole] = useState<boolean>(true);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
setTimeout(() => {
setCheckingRole(false);
}, 2000);
async function create() { async function create() {
//validação dos campos //validação dos campos
@@ -115,14 +110,6 @@ export default function CreateUser() {
} }
} }
if (checkingRole) {
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return ( return (
<div className={`${styles.container} p-3 p-xl-0`}> <div className={`${styles.container} p-3 p-xl-0`}>
<div className={"text-start"}> <div className={"text-start"}>

View File

@@ -16,15 +16,10 @@ export default function CreateVideo() {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [category_ids, setCategoryIds] = useState<string[]>([]); const [category_ids, setCategoryIds] = useState<string[]>([]);
const [checkingRole, setCheckingRole] = useState(true);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [order, setOrder] = useState<number>(0); const [order, setOrder] = useState<number>(0);
setTimeout(() => {
setCheckingRole(false);
}, 2000);
useEffect(() => { useEffect(() => {
getCategories(); getCategories();
}, []); }, []);
@@ -210,14 +205,6 @@ export default function CreateVideo() {
); );
}; };
if (checkingRole) {
return (
<div className="text-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return ( return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}> <div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}> <div className={"text-start"}>

View File

@@ -17,13 +17,8 @@ export default function CreateWorkshop() {
const [time_end, setTimeEnd] = useState<Date | null>(null); const [time_end, setTimeEnd] = useState<Date | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [checkingRole, setCheckingRole] = useState(true);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
setTimeout(() => {
setCheckingRole(false);
}, 3000);
//função para formatar a data para o formato Local //função para formatar a data para o formato Local
function formatDateLocalISO(d: Date) { function formatDateLocalISO(d: Date) {
const yyyy = d.getFullYear(); const yyyy = d.getFullYear();
@@ -94,14 +89,6 @@ export default function CreateWorkshop() {
} }
} }
if (checkingRole) {
return (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
)
}
return ( return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}> <div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}> <div className={"text-start"}>

View File

@@ -117,6 +117,8 @@ export default function editVideo() {
category_ids.forEach((categoryId) => { category_ids.forEach((categoryId) => {
formData.append("category_ids[]", categoryId); formData.append("category_ids[]", categoryId);
}); });
// Mesmo que fique vazio, força o backend a sincronizar (sync([]) remove pivots antigas)
formData.append("sync_categories", "1");
// Mantém compatibilidade de upload de ficheiro com Laravel // Mantém compatibilidade de upload de ficheiro com Laravel
// quando a rota aceita PATCH (method spoofing) // quando a rota aceita PATCH (method spoofing)
@@ -241,7 +243,6 @@ export default function editVideo() {
{loading ? ( {loading ? (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center"> <div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar dados do vídeo...</span>
</div> </div>
) : ( ) : (
<div className={`${styles.container} p-3 p-xl-0`}> <div className={`${styles.container} p-3 p-xl-0`}>

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import type { ApiErrorResponse, User, Workshop } from "../../../../types"; import type { ApiErrorResponse, Workshop } from "../../../../types";
import { LuArrowLeft, LuCalendar, LuCheck, LuClock3, LuPencil, LuTrash2, LuUpload, LuUsers, LuX} from "react-icons/lu"; import { LuArrowLeft, LuCalendar, LuCheck, LuClock3, LuPencil, LuTrash2, LuUpload, LuUsers, LuX} from "react-icons/lu";
import { pt } from "date-fns/locale"; import { pt } from "date-fns/locale";
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
@@ -11,10 +11,6 @@ import styles from "./styles.module.css";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton"; import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
export default function Workshop() { 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 { id } = useParams();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [workshop, setWorkshop] = useState<Workshop | null>(null); const [workshop, setWorkshop] = useState<Workshop | null>(null);
@@ -28,6 +24,7 @@ export default function Workshop() {
const [time_end, setTimeEnd] = useState<Date | null>(null); const [time_end, setTimeEnd] = useState<Date | null>(null);
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const [listagemInscritos, setListagemInscritos] = useState<boolean>(false); const [listagemInscritos, setListagemInscritos] = useState<boolean>(false);
const [role, setRole] = useState<number>(0);
/* Para enviar a data no formato que o backend espera */ /* Para enviar a data no formato que o backend espera */
function formatDateToYmd(value: Date): string { function formatDateToYmd(value: Date): string {
@@ -64,6 +61,7 @@ export default function Workshop() {
if (response.ok) { if (response.ok) {
setWorkshop(data.data); setWorkshop(data.data);
setStatus(data.data.status); setStatus(data.data.status);
setRole(data.role);
setLoading(false); setLoading(false);
} else { } else {
setWorkshop(null); setWorkshop(null);
@@ -187,7 +185,6 @@ export default function Workshop() {
return ( return (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center"> <div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar dados do workshop...</span>
</div> </div>
) )
} }
@@ -217,7 +214,7 @@ export default function Workshop() {
</div> </div>
<div className="col-12 col-lg-9 d-flex flex-column justify-content-between gap-2"> <div className="col-12 col-lg-9 d-flex flex-column justify-content-between gap-2">
<div className="d-flex flex-column flex-wrap gap-1 justify-content-center justify-content-md-start"> <div className="d-flex flex-column flex-wrap gap-1 justify-content-center justify-content-md-start">
{isAdmin ? ( {role === 1 ? (
<span className={`${styles.statusWorkshop} text-center d-inline-block py-1 mb-1 mb-sm-0 ${workshop.status === "pending" ? "alert alert-primary" : workshop.status === "realized" ? "alert alert-success" : "alert alert-danger"}`} style={{ width: "fit-content" }}> <span className={`${styles.statusWorkshop} text-center d-inline-block py-1 mb-1 mb-sm-0 ${workshop.status === "pending" ? "alert alert-primary" : workshop.status === "realized" ? "alert alert-success" : "alert alert-danger"}`} style={{ width: "fit-content" }}>
<span className="fw-bold">{workshop.status === "pending" ? (<><LuClock3 className="mb-1" /> Agendado</>) : workshop.status === "realized" ? (<><LuCheck className="mb-1" /> Realizado</>) : (<><LuX className="mb-1" /> Cancelado</>)}</span> <span 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> </span>
@@ -239,12 +236,12 @@ export default function Workshop() {
{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")} {workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
</div> </div>
{isAdmin && workshop.users.length === 0 ? ( {role === 1 && workshop.users.length === 0 ? (
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}> <div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
<small className="d-block text-muted me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small> <small className="d-block text-muted me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}>0 utilizadores</span> <span className={`${styles.contagemUtilizadoresInscritos} fs-6`}>0 utilizadores</span>
</div> </div>
) : isAdmin && workshop.users.length > 0 ? ( ) : role === 1 && workshop.users.length > 0 ? (
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}> <div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
<small className="d-block text-muted mb-1 me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small> <small className="d-block text-muted mb-1 me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
<span className={`${styles.contagemUtilizadoresInscritos} fs-6 px-0`}>{workshop.users.length === 1 ? "1 utilizador" : `${workshop.users.length} utilizadores`} </span> <span className={`${styles.contagemUtilizadoresInscritos} fs-6 px-0`}>{workshop.users.length === 1 ? "1 utilizador" : `${workshop.users.length} utilizadores`} </span>
@@ -298,7 +295,7 @@ export default function Workshop() {
) : null} ) : null}
</div> </div>
{!formEdit && isAdmin ? ( {!formEdit && role === 1 ? (
<div className={`${styles.buttonsContainer} d-flex flex-wrap gap-2 justify-content-center mt-5`}> <div className={`${styles.buttonsContainer} d-flex flex-wrap gap-2 justify-content-center mt-5`}>
<button <button
type="button" type="button"

View File

@@ -1,31 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import type { ApiErrorResponse, User, Video, Workshop } from "../../../../types"; import type { ApiErrorResponse, User, Workshop } from "../../../../types";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { LuCheck, LuTrash2, LuX, LuPencil, LuArrowLeft } from "react-icons/lu"; import { LuCheck, LuTrash2, LuX, LuPencil, LuArrowLeft, LuCalendar, LuClock3 } from "react-icons/lu";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { useGetWorkshops } from "../../../../hooks/useGetWorkshops";
export default function User() { export default function User() {
const { id } = useParams(); const { id } = useParams();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [formEdit, setFormEdit] = useState<boolean>(false); const [formEdit, setFormEdit] = useState<boolean>(false);
const [workshops, setWorkshops] = useState<Workshop[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null); const [error, setError] = useState<ApiErrorResponse | null>(null);
const { getWorkshops } = useGetWorkshops();
const navigate = useNavigate(); const navigate = useNavigate();
const [videosWatched, setVideosWatched] = useState(0);
const [videosCount, setVideosCount] = useState(0);
const [workshopsInscribed, setWorkshopsInscribed] = useState(0);
const [workshopsParticipated, setWorkshopsParticipated] = useState<Workshop[]>([]);
useEffect(() => { useEffect(() => {
const fetchAll = async () => {
const [workshopsData] = await Promise.all([
getWorkshops(),
]);
setWorkshops(workshopsData as Workshop[]);
getUser(); getUser();
};
fetchAll();
}, [id]); }, [id]);
async function getUser() { async function getUser() {
@@ -44,6 +39,10 @@ export default function User() {
if (response.ok) { if (response.ok) {
setUser(data.data); setUser(data.data);
setVideosWatched(data.videosWatched);
setVideosCount(data.videosCount);
setWorkshopsInscribed(data.workshopsInscribed);
setWorkshopsParticipated(data.workshopsParticipated as Workshop[]);
} else { } else {
setError(data as ApiErrorResponse); setError(data as ApiErrorResponse);
setUser(null); setUser(null);
@@ -166,11 +165,11 @@ export default function User() {
</div> </div>
{ user ? ( {user ? (
<div className="my-3"> <div className="my-3">
<span className={styles.title}>Dados do Utilizador </span> <span className={styles.title}>Dados do Utilizador </span>
<div className="row mt-5 justify-content-between"> <div className="row mt-5 justify-content-between px-2">
<div className="col-12 col-lg-8 p-2 rounded-3"> <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={`${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"> <div className="col-12 col-md-6 d-flex flex-column text-start flex-column mt-xl-0">
@@ -197,16 +196,60 @@ export default function User() {
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}> <div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column"> <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="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> <span className="fs-2 fw-bold text-white">{videosWatched}/{videosCount}</span>
</div> </div>
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Workshops inscrito</span> <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> <span className="fs-2 fw-bold text-white">{workshopsInscribed}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 p-2 mt-4 mb-3">
<span className={`${styles.subtitle} text-start`}>Workshops inscrito</span>
</div>
{workshopsParticipated.length > 0 ? (
workshopsParticipated.map((workshop) => (
<div key={workshop.id} className="col-12 col-sm-6 col-lg-4 p-2">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
alt={workshop.title}
className={styles.thumbnailWorkshop}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
<span className={`${styles.workshopStatus} position-absolute top-0 end-0 m-2 badge ${workshop.status === "pending" ? "bg-warning text-dark" : workshop.status === "realized" ? "bg-success" : "bg-danger"}`}>
{workshop.status === "pending" ? "Pendente" : workshop.status === "realized" ? "Realizado" : "Cancelado"}
</span>
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex mb-2 px-2">
<p className={`${styles.dateWorkshop} mb-0`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split("-").reverse().join("-")}</p>
<p className={`${styles.timeWorkshop} mb-0`}><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>
<div className="d-flex flex-column d-md-none mt-2">
<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)} - {workshop.time_end.slice(0, 5)}</span>
</div>
</div>
<div className="px-3 mt-3">
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-4 text-decoration-none`}>
Ver detalhes
</Link>
</div>
</div>
</div>
))
) : (
<div className="col-12 text-center ps-1">
<span className="text-muted fs-5">Este utilizador ainda não participou em nenhum workshop</span>
</div>
)}
</div> </div>
<div className="mt-5"> <div className="mt-5">

View File

@@ -9,6 +9,66 @@
font-size: var(--size-font-title); font-size: var(--size-font-title);
} }
.subtitle {
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
.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);
height: 150px;
}
.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;
}
.linkWorkshop {
display: block;
width: fit-content;
border-radius: var(--border-radius);
font-weight: 600;
background-color: var(--bg-grey);
color: var(--text-black);
transition: all 0.3s ease;
&:hover {
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}
.workshopStatus {
z-index: 2;
}
.animateSpin{ .animateSpin{
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@@ -71,7 +131,7 @@
.userVideosWatched{ .userVideosWatched{
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: var(--bg-primary-color); background-color: var(--bg-neutral-color);
} }
.closeFormButton{ .closeFormButton{

View File

@@ -1,26 +1,21 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDebounce } from "../../../../hooks/useDebounce"; import { useDebounce } from "../../../../hooks/useDebounce";
import type { ApiErrorResponse, User } from "../../../../types"; import type { ApiErrorResponse, User } from "../../../../types";
import { Link, Navigate } from "react-router"; import { Link } from "react-router";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { LuPencil, LuSettings2 } from "react-icons/lu"; import { LuPencil, LuSettings2, LuPlus } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import { LuPlus } from "react-icons/lu"; import { Form, Pagination, Table } from "react-bootstrap";
import { Dropdown, Form, Pagination, Table } from "react-bootstrap"; import { AnimatePresence, motion } from "framer-motion";
export default function Users() { 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 [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null); const [error, setError] = useState<ApiErrorResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [selectedUsers, setSelectedUsers] = useState<string>("all"); const [selectedUsers, setSelectedUsers] = useState<string>("all");
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1); const [lastPage, setLastPage] = useState(1);
const [listTotal, setListTotal] = useState(0); const [listTotal, setListTotal] = useState(0);
@@ -68,8 +63,8 @@ export default function Users() {
if (loading) { if (loading) {
return ( return (
<div className="text-center mt-5"> <div className="d-flex flex-column text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> <CgSpinner className={`${styles.animateSpin} mx-auto text-2xl fs-3`} />
</div> </div>
) )
} }
@@ -96,22 +91,56 @@ export default function Users() {
</div> </div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4"> <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) => { <div
if (value) { className="btn-group flex-grow-1 position-relative"
setSelectedUsers(value); onMouseEnter={() => setShowFilterDropdown(true)}
setCurrentPage(1); onMouseLeave={() => setShowFilterDropdown(false)}
setLoadingUsers(true); >
} <button type="button" className="btn btn-outline-secondary w-100 dropdown-toggle">
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100">
<LuSettings2 /> {selectedUsers === 'all' ? 'Todos' : selectedUsers === 'admin' ? 'Administradores' : 'Utilizadores'} <LuSettings2 /> {selectedUsers === 'all' ? 'Todos' : selectedUsers === 'admin' ? 'Administradores' : 'Utilizadores'}
</Dropdown.Toggle> </button>
<Dropdown.Menu className="text-center w-100">
<Dropdown.Item eventKey="all" active={selectedUsers === 'all'}>Todos</Dropdown.Item> <AnimatePresence>
<Dropdown.Item eventKey="admin" active={selectedUsers === 'admin'}>Administradores</Dropdown.Item> {showFilterDropdown && (
<Dropdown.Item eventKey="user" active={selectedUsers === 'user'}>Utilizadores</Dropdown.Item> <motion.ul
</Dropdown.Menu> initial={{ opacity: 0, y: -10, scale: 0.98 }}
</Dropdown> animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="dropdown-menu text-center w-100"
style={{ display: "block", zIndex: 4000, top: "100%", marginTop: "0.25rem" }}
>
<li>
<button
type="button"
className={`dropdown-item ${selectedUsers === "all" ? "active" : ""}`}
onClick={() => { setSelectedUsers("all"); setCurrentPage(1); setLoadingUsers(true); setShowFilterDropdown(false); }}
>
Todos
</button>
</li>
<li>
<button
type="button"
className={`dropdown-item ${selectedUsers === "admin" ? "active" : ""}`}
onClick={() => { setSelectedUsers("admin"); setCurrentPage(1); setLoadingUsers(true); setShowFilterDropdown(false); }}
>
Administradores
</button>
</li>
<li>
<button
type="button"
className={`dropdown-item ${selectedUsers === "user" ? "active" : ""}`}
onClick={() => { setSelectedUsers("user"); setCurrentPage(1); setLoadingUsers(true); setShowFilterDropdown(false); }}
>
Utilizadores
</button>
</li>
</motion.ul>
)}
</AnimatePresence>
</div>
</div> </div>
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4"> <div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4">
@@ -199,8 +228,6 @@ export default function Users() {
)} )}
</div> </div>
)} )}
</div> </div>
) )
} }

View File

@@ -7,9 +7,9 @@ import { Navigate } from "react-router";
import AccordionFAQS from "../../../components/accordion FAQ´s/accordionFAQS"; import AccordionFAQS from "../../../components/accordion FAQ´s/accordionFAQS";
export default function Contactos() { export default function Contactos() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string); const token = localStorage.getItem("token");
if (!user) { if (!token) {
return <Navigate to="/login" />; return <Navigate to="/login" />;
} }

View File

@@ -1,86 +1,72 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ApiErrorResponse, User, Video, Workshop } from "../../../types"; import type { ApiErrorResponse, Video, Workshop, DashboardResponse } from "../../../types";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { LuArrowUpRight, LuCalendar, LuClock3, LuPencil } from "react-icons/lu"; import { LuArrowUpRight, LuCalendar, LuClock3, LuPencil } from "react-icons/lu";
import { Link } from "react-router"; import { Link } from "react-router";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import { ProgressBar } from "react-bootstrap"; import { ProgressBar } from "react-bootstrap";
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser" /* import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser"
import { useGetVideosLength } from "../../../hooks/useGetVideosLength"; import { useGetVideosLength } from "../../../hooks/useGetVideosLength";
import { useNextVideos } from "../../../hooks/useNextVideos"; import { useNextVideos } from "../../../hooks/useNextVideos";
import { useNextWorkshops } from "../../../hooks/useNextWorkshops"; import { useNextWorkshops } from "../../../hooks/useNextWorkshops";
import { useGetWorkshopsLength } from "../../../hooks/useGetWorkshopsLength"; import { useGetWorkshopsLength } from "../../../hooks/useGetWorkshopsLength"; */
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { PiCheckCircleFill } from "react-icons/pi"; import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton"; import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
export default function Home() { 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 [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiErrorResponse | null>(null); const [error, setError] = useState<ApiErrorResponse | null>(null);
const [messageSuccess, setMessageSuccess] = useState<string | null>(null); const [messageSuccess, setMessageSuccess] = useState<string | null>(null);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const { getCurrentUser } = useGetCurrentUser(); const [videos, setVideos] = useState<Video[]>([]);
const [currentUserData, setCurrentUserData] = useState<User | null>(null); const [videosWatched, setVideosWatched] = useState(0);
const { getVideosLength } = useGetVideosLength(); const [videosCount, setVideosCount] = useState(0);
const { getNextVideos } = useNextVideos(); const [workshops, setWorkshops] = useState<Workshop[]>([]);
const [nextVideos, setNextVideos] = useState<Video[]>([]); const [workshopsInscribed, setWorkshopsInscribed] = useState(0);
const { getNextWorkshops } = useNextWorkshops(); const [workshopsCount, setWorkshopsCount] = useState(0);
const [nextWorkshops, setNextWorkshops] = useState<Workshop[]>([]); const [role, setRole] = useState(0);
const { getWorkshopsLength } = useGetWorkshopsLength(); const [userId, setUserId] = useState<number | null>(null);
const [videosStats, setVideosStats] = useState({
videos: 0,
videosWatched: 0
});
const [workshopsStats, setWorkshopsStats] = useState({
workshops: 0,
workshopsInscribed: 0
});
useEffect(() => { useEffect(() => {
const fetchAll = async () => { getDashboard();
setLoading(true);
try {
const [
currentUserData,
videosLengthData,
workshopsLengthData,
nextVideosData,
nextWorkshopsData
] = await Promise.all([
getCurrentUser(),
getVideosLength(),
getWorkshopsLength(),
getNextVideos(),
getNextWorkshops(),
]);
setCurrentUserData((currentUserData as { data: User }).data);
setVideosStats(videosLengthData as { videos: number, videosWatched: number });
setWorkshopsStats(workshopsLengthData as { workshops: number, workshopsInscribed: number });
if ("videos" in nextVideosData) {
setNextVideos(nextVideosData.videos);
}
if ("workshops" in nextWorkshopsData) {
setNextWorkshops(nextWorkshopsData.workshops as unknown as Workshop[]);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
fetchAll();
}, []); }, []);
async function getDashboard() {
setLoading(true);
const response = await fetch("http://127.0.0.1:8000/api/dashboard", {
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.videos);
setVideosWatched(data.videosWatched);
setVideosCount(data.videosCount);
setWorkshops(data.workshops);
setWorkshopsInscribed(data.workshopsInscribed);
setWorkshopsCount(data.workshopsCount);
setRole(data.role);
setUserId(data.userId);
localStorage.setItem("videosWatched", data.videosWatched.toString());
localStorage.setItem("videosCount", data.videosCount.toString());
setLoading(false);
} else {
setError(data as ApiErrorResponse);
setLoading(false);
}
}
/* Vídeos */ /* Vídeos */
let progressoVideos = Math.round(videosStats.videosWatched / videosStats.videos * 100); let progressoVideos = Math.round(videosWatched / videosCount * 100);
/* Inscrever num workshop */ /* Inscrever num workshop */
async function inscrever(workshopId: number) { async function inscrever(workshopId: number) {
@@ -96,17 +82,14 @@ export default function Home() {
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
setMessageSuccess(data.message);
Swal.fire({ Swal.fire({
title: data.message, title: data.message,
icon: 'success', icon: 'success',
showConfirmButton: false, showConfirmButton: false,
showCloseButton: true, showCloseButton: true,
}); });
const result = await getNextWorkshops(); getDashboard();
if ("workshops" in result) { setWorkshops(data.workshops as DashboardResponse['workshops']);
setNextWorkshops(result.workshops as unknown as Workshop[]);
}
} else { } else {
Swal.fire({ Swal.fire({
title: data.message, title: data.message,
@@ -137,10 +120,8 @@ export default function Home() {
showConfirmButton: false, showConfirmButton: false,
showCloseButton: true, showCloseButton: true,
}); });
const result = await getNextWorkshops(); getDashboard();
if ("workshops" in result) { setWorkshops(data.workshops as DashboardResponse['workshops']);
setNextWorkshops(result.workshops as unknown as Workshop[]);
}
} else { } else {
Swal.fire({ Swal.fire({
title: data.message, title: data.message,
@@ -200,7 +181,6 @@ export default function Home() {
return ( return (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center"> <div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar a página...</span>
</div> </div>
) )
} }
@@ -210,11 +190,11 @@ export default function Home() {
<div className={`${styles.containerVideos} px-2 p-sm-4`}> <div className={`${styles.containerVideos} px-2 p-sm-4`}>
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center px-0" > <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 `}>{isAdmin ? "Vídeos ativos" : "Continuar Formação"}</h2> <h2 className={`${styles.subtitle} text-center text-sm-start mt-4 mt-sm-3 mb-4 px-0 `}>{role === 1 ? "Vídeos ativos" : "Continuar Formação"}</h2>
<Link to="/videos" className={`${styles.link} text-decoration-none fw-semibold fs-5 mt-sm-3 mb-4`}>Ver vídeos <LuArrowUpRight className="mb-1 me-2" size={25} /></Link> <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> </div>
{!isAdmin && ( {role !== 1 && (
<> <>
<ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} /> <ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} />
@@ -227,11 +207,11 @@ export default function Home() {
)} )}
<div className="row mt-4 px-2"> <div className="row mt-4 px-2">
{nextVideos.length > 0 ? nextVideos.map((video: Video) => ( {videos.length > 0 ? videos.map((video: Video) => (
<div className="col-12 col-sm-6 col-lg-4 p-2"> <div className="col-12 col-sm-6 col-lg-4 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}> <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(', ')} > <div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && ( {role === 1 && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link> <Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)} )}
<img <img
@@ -249,7 +229,7 @@ export default function Home() {
</div> </div>
</Link> </Link>
</div> </div>
)) : nextVideos.length === 0 ? ( )) : videos.length === 0 ? (
<div className="col-12 text-start ps-1"> <div className="col-12 text-start ps-1">
<span className="text-muted fs-5">Nenhum vídeo encontrado</span> <span className="text-muted fs-5">Nenhum vídeo encontrado</span>
</div> </div>
@@ -263,7 +243,7 @@ export default function Home() {
<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> <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>
<div className="row mt-4 mt-sm-1 px-2"> <div className="row mt-4 mt-sm-1 px-2">
{nextWorkshops.length > 0 ? nextWorkshops.map((workshop: Workshop) => ( {workshops.length > 0 ? workshops.map((workshop: Workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative"> <div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}> <div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}> <div className={`${styles.thumbnailWorkshop} position-relative`}>
@@ -287,10 +267,10 @@ export default function Home() {
</div> </div>
</div> </div>
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3"> <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> <Link to={`${role === 1 ? `/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 ? ( {role !== 1 ? (
currentUserData && (workshop.users as unknown as number[]).includes(currentUserData.id) ? ( (workshop.users as unknown as number[]).includes(userId as number) ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => cancelarInscricao(workshop.id)} key={workshop.id} style={{ width: '180px' }}> <button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => cancelarInscricao(workshop.id)} key={workshop.id} style={{ width: '180px' }}>
Anular inscrição Anular inscrição
</button> </button>
@@ -304,7 +284,7 @@ export default function Home() {
</div> </div>
</div> </div>
) )
) : nextWorkshops.length === 0 ? ( ) : workshops.length === 0 ? (
<div className="col-12 text-center text-sm-start px-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> <span className="text-black text-muted fs-5 ms-sm-2 ps-sm-1">Sem workshops agendados</span>
</div> </div>
@@ -368,13 +348,13 @@ export default function Home() {
<div className="h-100 pt-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={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-normal fs-3 text-white" > {isAdmin ? "Vídeos ativos" : "Vídeos assistidos"}</span> <span className="fw-normal fs-3 text-white" > {role === 1 ? "Vídeos ativos" : "Vídeos assistidos"}</span>
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{isAdmin ? videosStats.videos : `${videosStats.videosWatched}/${videosStats.videos}`}</span> <span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{role === 1 ? videosCount : `${videosWatched}/${videosCount}`}</span>
</div> </div>
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>{isAdmin ? "Workshops agendados" : "Workshops inscrito"}</span> <span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>{role === 1 ? "Workshops agendados" : "Workshops inscrito"}</span>
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{isAdmin ? workshopsStats.workshops : `${workshopsStats.workshopsInscribed}/${workshopsStats.workshops}`}</span> <span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{role === 1 ? workshopsCount : `${workshopsInscribed}/${workshopsCount}`}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,45 +1,74 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ApiErrorResponse, User, Video, Workshop } from "../../../types"; import type { User, Video, Workshop } from "../../../types";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { LuCheck, LuKeyRound, LuPencil, LuX } from "react-icons/lu"; import { LuCheck, LuKeyRound, LuPencil, LuX, LuArrowUpRight, LuClock3, LuCalendar } from "react-icons/lu";
import { useGetWorkshops } from "../../../hooks/useGetWorkshops"; import { CgSpinner } from "react-icons/cg";
import { Link } from "react-router";
import { ProgressBar } from "react-bootstrap";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { PiCheckCircleFill } from "react-icons/pi";
import Swal from "sweetalert2";
export default function Profile() { export default function Profile() {
const [user, setUser] = useState<User | null>(JSON.parse(localStorage.getItem("user") as unknown as string)); const [role, setRole] = useState(0);
const [error, setError] = useState<ApiErrorResponse | null>(null); const [userId, setUserId] = useState(0);
const isAdmin = user?.role_id === 1; const [loading, setLoading] = useState(true);
/* const [loading, setLoading] = useState(true); */
const [formEdit, setFormEdit] = useState<boolean>(false); const [formEdit, setFormEdit] = useState<boolean>(false);
const [formEditPassword, setFormEditPassword] = useState<boolean>(false); const [formEditPassword, setFormEditPassword] = useState<boolean>(false);
const [messageUpdateUser, setMessageUpdateUser] = useState<string>(""); const [userData, setUserData] = useState<User | null>(null);
const [messageUpdatePassword, setMessageUpdatePassword] = useState<string>("");
const { getWorkshops } = useGetWorkshops();
const [workshops, setWorkshops] = useState<Workshop[]>([]); const [workshops, setWorkshops] = useState<Workshop[]>([]);
const [workshopsCount, setWorkshopsCount] = useState(0);
const [nextWorkshops, setNextWorkshops] = useState<Workshop[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [videosCount, setVideosCount] = useState(0);
const [videosWatched, setVideosWatched] = useState(0);
const [progressoVideos, setProgressoVideos] = useState(0);
useEffect(() => { useEffect(() => {
const fetchAll = async () => { getProfile();
const [workshopsData] = await Promise.all([
getWorkshops(),
]);
setWorkshops(workshopsData as Workshop[]);
};
fetchAll();
}, []); }, []);
async function getProfile() {
setLoading(true);
const response = await fetch("http://127.0.0.1:8000/api/profile", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
setUserData(data.data as User);
setWorkshops(data.workshopsInscribed as Workshop[]);
setWorkshopsCount(data.workshopsCount);
setVideos(data.videos as Video[]);
setVideosCount(data.videosCount);
setNextWorkshops(data.nextWorkshops as Workshop[]);
setVideosWatched(data.videosWatched);
setUserId(data.userId);
setRole(data.role);
setProgressoVideos(Math.round(data.videosWatched / data.videosCount * 100));
setLoading(false);
};
async function updatePassword(event: React.FormEvent<HTMLFormElement>) { async function updatePassword(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const user_id = user?.id; const user_id = userData?.id;
const passwordAtual = formData.get("passwordAtual"); const passwordAtual = formData.get("passwordAtual");
const novaPassword = formData.get("novaPassword"); const novaPassword = formData.get("novaPassword");
const confirmarPassword = formData.get("confirmarPassword"); const confirmarPassword = formData.get("confirmarPassword");
if (novaPassword !== confirmarPassword) { if (novaPassword !== confirmarPassword) {
setError({ Swal.fire({
message: "As passwords não coincidem", title: "As passwords não coincidem",
data: null, icon: 'error',
errors: {}, showConfirmButton: false,
showCloseButton: true,
}); });
return; return;
} }
@@ -64,24 +93,29 @@ export default function Profile() {
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
setMessageUpdatePassword("Palavra-passe atualizada com sucesso"); Swal.fire({
setTimeout(() => { title: data.message as string,
setMessageUpdatePassword(""); icon: 'success',
}, 3000) showConfirmButton: false,
showCloseButton: true,
});
setFormEditPassword(false); setFormEditPassword(false);
setError(null);
} else { } else {
setMessageUpdatePassword(""); Swal.fire({
setError(data as ApiErrorResponse); title: data.message as string,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
} }
} }
async function update(event: React.FormEvent<HTMLFormElement>) { async function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const user_id = user?.id; const user_id = userData?.id;
const name = formData.get("name"); const name = formData.get("name") || userData?.name;
const email = formData.get("email"); const email = formData.get("email") || userData?.email;
const payload = { const payload = {
user_id: user_id, user_id: user_id,
@@ -102,32 +136,63 @@ export default function Profile() {
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
setUser(data.data);
setFormEdit(false); setFormEdit(false);
setMessageUpdateUser(data.message as string); Swal.fire({
setTimeout(() => { title: data.message,
setMessageUpdateUser(""); icon: 'success',
}, 3000) showConfirmButton: false,
setError(null); showCloseButton: true,
});
getProfile();
} else { } else {
setMessageUpdateUser(""); Swal.fire({
setError(data as ApiErrorResponse); 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")}`
},
});
/* { user ? { const data = await response.json();
} : {
if (response.ok) {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
getProfile();
setWorkshops(data.workshops as Workshop[]);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
} }
}
if (loading) {
return ( return (
<div className="text-center"> <div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar os seus dados...</span>
</div> </div>
) );
} */ }
return ( return (
<div className="container"> <div className="container">
@@ -140,43 +205,43 @@ export default function Profile() {
<div className="row justify-content-between"> <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="col-12 d-flex text-start mt-xl-0 justify-content-between flex-grow-1 mb-3">
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-bold fs-4 mt-1">{user?.name}</span> <span className="fw-bold fs-4 mt-1">{userData?.name}</span>
<span className="fs-5 flex-grow-1">{user?.email}</span> <span className="fs-5 flex-grow-1">{userData?.email}</span>
</div> </div>
</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"> <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> <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(userData?.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"> <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.updateButton} text-decoration-none text-primary text-center px-3`} onClick={() => { setFormEdit(true); }}> Editar <LuPencil className={`${styles.icon} mb-1 text-primary`} /></a>
<a className={`${styles.passwordButton} text-decoration-none text-danger px-1 px-md-3 <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> `} onClick={() => { setFormEditPassword(true); }}> Alterar password <LuKeyRound className={`${styles.icon} mb-1 text-danger`} /></a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 col-lg-4 p-3"> <div className="col-12 col-lg-4 p-3">
{isAdmin ? ( {role === 1 ? (
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}> <div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column"> <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="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>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> <span className="fs-2 fw-bold text-white">{videosCount}</span>
</div> </div>
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Workshops agendados</span> <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> <span className="fs-2 fw-bold text-white">{workshopsCount}</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}> <div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column"> <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="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>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> <span className="fs-2 fw-bold text-white">{videosWatched}/{videosCount}</span>
</div> </div>
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Workshops inscrito</span> <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> <span className="fs-2 fw-bold text-white">{workshopsCount}</span>
</div> </div>
</div> </div>
)} )}
@@ -185,39 +250,23 @@ export default function Profile() {
</div> </div>
</div> </div>
{messageUpdateUser || messageUpdatePassword ? (
<div className="">
<div className="alert alert-success mt-4">
{messageUpdateUser || messageUpdatePassword}
</div>
</div>
) : null}
{formEdit ? ( {formEdit ? (
<div className="my-3"> <div className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<span className={styles.title}>Editar dados</span> <span className={styles.title}>Editar dados</span>
<form method="patch" onSubmit={update} id="formEditUser"> <form method="patch" onSubmit={update} id="formEditUser">
<div className="row g-3 mt-2"> <div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} /> <input type="hidden" name="user_id" value={userId} />
<div className="col-12 col-md-6 text-start"> <div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label> <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} /> <input type="text" className="form-control py-2" id="name" name="name" defaultValue={userData?.name} />
</div> </div>
<div className="col-12 col-md-6 text-start"> <div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="email">Email</label> <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} /> <input type="email" className="form-control py-2" id="email" name="email" defaultValue={userData?.email} />
</div> </div>
</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" > <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="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> <button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
@@ -225,12 +274,12 @@ export default function Profile() {
</form> </form>
</div> </div>
) : formEditPassword ? ( ) : formEditPassword ? (
<div className="my-3"> <div className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<span className={styles.title}>Alterar password</span> <span className={styles.title}>Alterar password</span>
<form method="patch" onSubmit={updatePassword} id="formEditPassword"> <form method="patch" onSubmit={updatePassword} id="formEditPassword">
<div className="row g-3 mt-2"> <div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} /> <input type="hidden" name="user_id" value={userId} />
<div className="col-12 col-lg-4 text-start"> <div className="col-12 col-lg-4 text-start">
<label className="form-label fw-bold" htmlFor="passwordAtual">Password atual</label> <label className="form-label fw-bold" htmlFor="passwordAtual">Password atual</label>
<input type="password" className="form-control py-2" id="passwordAtual" name="passwordAtual" /> <input type="password" className="form-control py-2" id="passwordAtual" name="passwordAtual" />
@@ -245,14 +294,6 @@ export default function Profile() {
</div> </div>
</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" > <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="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> <button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
@@ -260,6 +301,141 @@ export default function Profile() {
</form> </form>
</div> </div>
) : null} ) : null}
<div className={`${styles.containerVideos} mt-4 px-2 p-sm-4 mx-1`}>
<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 `}>{role === 1 ? "Vídeos ativos" : "Continuar Formação"}</h2>
<Link to="/videos" className={`${styles.link} text-decoration-none fw-semibold fs-5 mt-sm-3 mb-4`}>Ver vídeos <LuArrowUpRight className="mb-1 me-2" size={25} /></Link>
</div>
{role !== 1 && (
<>
<ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} />
{progressoVideos === 100 && (
<div className="text-center mt-3">
<span className="text-black fw-bold fs-6">Parabéns! A sua formação está completa!</span>
</div>
)}
</>
)}
<div className="row mt-4 px-2">
{videosCount > 0 ? videos.map((video: Video) => (
<div className="col-12 col-sm-6 col-lg-4 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{role === 1 && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<img
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
alt={video.title}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
<h2 className={`${styles.titleVideo} d-flex text-wrap mb-1`}>{video.title}</h2>
</div>
{video.watched && <span className="d-block badge text-success text-start fs-6 position-absolute top-0 start-0 bg-success-subtle text-success px-3 py-2" style={{ borderRadius: "0 0 var(--border-radius) 0" }}><PiCheckCircleFill className="mb-1 me-1" /> Visto</span>}
</div>
</Link>
</div>
)) : videosCount === 0 ? (
<div className="col-12 text-start ps-1">
<span className="text-muted fs-5">Nenhum vídeo encontrado</span>
</div>
) : null}
</div>
</div>
<div className="ms-0 px-4 mt-4">
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center px-0" >
<h2 className={`${styles.subtitle} text-center text-sm-start mt-4 mt-sm-3 mb-4`}>{role === 1 ? "Workshops agendados" : "Workshops Inscrito"}</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 px-2 mb-5">
{role !== 1 && workshopsCount > 0 ? workshops.map((workshop: Workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
alt={workshop.title}
className={styles.thumbnailWorkshop}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div className="px-3">
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
</div>
</div>
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
<Link to={`${role === 1 ? `/admin/edit-workshop/${workshop.id}` : `/workshop/${workshop.id}`}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}> Detalhes </Link>
{role !== 1 ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => cancelarInscricao(workshop.id)} key={workshop.id} style={{ width: '180px' }}>
Anular inscrição
</button>
) : null}
</div>
</div>
</div>
)
) : workshopsCount === 0 ? (
<div className="col-12 text-center px-0 mt-3">
<span className="text-black text-muted fs-5 mb-5">Não está inscrito em nenhum workshop</span>
</div>
) : null}
{role === 1 && workshopsCount > 0 ? nextWorkshops.map((workshop: Workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
alt={workshop.title}
className={styles.thumbnailWorkshop}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div className="px-3">
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3 ">
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
</div>
</div>
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
<Link to={`${role === 1 ? `/admin/edit-workshop/${workshop.id}` : `/workshop/${workshop.id}`}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}> Detalhes </Link>
</div>
</div>
</div>
)
) : workshopsCount === 0 ? (
<div className="col-12 text-center px-0 mt-3">
<span className="text-black text-muted fs-5 mb-5">Não workshops agendados</span>
</div>
) : null}
</div>
</div>
</div> </div>
); );
} }

View File

@@ -9,6 +9,11 @@
font-size: var(--size-font-title); font-size: var(--size-font-title);
} }
.subtitle {
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
.userCard{ .userCard{
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: var(--bg-white); background-color: var(--bg-white);
@@ -91,6 +96,189 @@
} }
} }
.containerVideos, .containerForm {
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.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);
}
}
.progressBar :global(.progress-bar) {
position: relative;
overflow: visible;
color: var(--text-primary-color);
font-weight: 800;
background-color: var(--bg-primary-color-opacity);
}
.linkVideo {
display: block;
width: 100%;
border-radius: var(--border-radius);
transition: all 0.3s ease;
background-color: var(--bg-white);
}
.boxVideo {
background: #e0e0e0; /* cinzento enquanto a imagem não carrega */
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.boxVideo::after {
content: '\F4F4';
color: var(--bg-grey);
font-size: 4rem;
align-content: center;
font-family: 'bootstrap-icons';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
transition: all 0.3s ease;
}
.boxVideo:hover::after {
color: var(--bg-primary-color);
}
.boxVideoInfo {
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;
}
.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);
}
}
.animateSpin{ .animateSpin{
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }

View File

@@ -1,37 +0,0 @@
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

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

View File

@@ -1,26 +0,0 @@
.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

@@ -1,169 +0,0 @@
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";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
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}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
<div className="px-3 text-start text-truncate">
<h2 className={`${styles.titleVideo} mt-2`}>{video.title}</h2>
<span className="text-muted text-black mt-0
">{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

@@ -1,95 +0,0 @@
.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{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.titleVideo{
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 skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -1,126 +0,0 @@
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";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
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={`${styles.thumbnailWorkshop} position-relative`}>
<img
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
alt={workshop.title}
className={styles.thumbnailWorkshop}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
<p className={`${styles.timeWorkshop}`}> <LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
</div>
</div>
<div 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

@@ -1,123 +0,0 @@
.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{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
height: 150px;
position: relative;
overflow: hidden;
}
.icon{
color: var(--text-primary-color);
}
.animateSpin{
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);
}
to{
transform: rotate(360deg);
}
}

View File

@@ -5,18 +5,19 @@ import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import { LuPlus, LuSettings2 } from "react-icons/lu"; import { LuPlus, LuSettings2 } from "react-icons/lu";
import { LuPencil } from "react-icons/lu"; import { LuPencil } from "react-icons/lu";
import { Dropdown, Form, Pagination } from "react-bootstrap"; import { Form, Pagination } from "react-bootstrap";
import { useGetVideos } from "../../../hooks/useGetVideos"; import { useGetVideos } from "../../../hooks/useGetVideos";
import { useDebounce } from "../../../hooks/useDebounce"; import { useDebounce } from "../../../hooks/useDebounce";
import { PiCheckCircleFill } from "react-icons/pi"; import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton"; import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { AnimatePresence, motion } from "framer-motion";
export default function Videos() { export default function Videos() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiErrorResponse | null>(null); const [error, setError] = useState<ApiErrorResponse | null>(null);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all"); const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all");
const [isAdmin, setIsAdmin] = useState(false); const [showFilterDropdown, setShowFilterDropdown] = useState(false);
const { getVideos } = useGetVideos(); const { getVideos } = useGetVideos();
const [videos, setVideos] = useState<Video[]>([]); const [videos, setVideos] = useState<Video[]>([]);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
@@ -25,49 +26,7 @@ export default function Videos() {
const [lastPage, setLastPage] = useState(1); const [lastPage, setLastPage] = useState(1);
const [loadingVideos, setLoadingVideos] = useState(false); const [loadingVideos, setLoadingVideos] = useState(false);
const videosToShow = videos; const videosToShow = videos;
const [role, setRole] = useState(0);
useEffect(() => {
getRole();
getCategories();
}, []);
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")}`
},
});
const data = await response.json();
if (response.ok) {
setCategories(data.data);
} else {
setCategories([]);
}
}
useEffect(() => { useEffect(() => {
const fetchVideos = async () => { const fetchVideos = async () => {
@@ -103,6 +62,8 @@ export default function Videos() {
setVideos(videosData.videos); setVideos(videosData.videos);
setLastPage(videosData.meta.last_page); setLastPage(videosData.meta.last_page);
setCurrentPage(videosData.meta.current_page); setCurrentPage(videosData.meta.current_page);
setRole(videosData.role);
setCategories(videosData.categories);
} else { } else {
setVideos([]); setVideos([]);
} }
@@ -145,42 +106,104 @@ export default function Videos() {
</div> </div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4"> <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) => { <div
setCurrentPage(1); className="btn-group flex-grow-1 position-relative"
if (value) { onMouseEnter={() => setShowFilterDropdown(true)}
setSelectedCategoryId(value); onMouseLeave={() => setShowFilterDropdown(false)}
} >
}}> <button type="button" className="btn btn-outline-secondary w-100 dropdown-toggle">
<Dropdown.Toggle variant="outline-secondary" className="w-100">
<LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' : <LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' :
selectedCategoryId === 'active' ? 'Ativos' : selectedCategoryId === 'active' ? 'Ativos' :
selectedCategoryId === 'inactive' ? 'Inativos' : selectedCategoryId === 'inactive' ? 'Inativos' :
selectedCategoryId === 'watched' ? 'Vistos' : selectedCategoryId === 'watched' ? 'Vistos' :
selectedCategoryId === 'unwatched' ? 'Não vistos' : selectedCategoryId === 'unwatched' ? 'Não vistos' :
categories.find(c => String(c.id) === selectedCategoryId)?.name ?? 'Todos'} categories.find(c => String(c.id) === selectedCategoryId)?.name ?? 'Todos'}
</Dropdown.Toggle> </button>
<Dropdown.Menu style={{ zIndex: 4000 }} className="text-center w-100">
<Dropdown.Item eventKey="all" active={selectedCategoryId === 'all'}>Todos</Dropdown.Item> <AnimatePresence>
{!isAdmin && ( {showFilterDropdown && (
<motion.ul
initial={{ opacity: 0, y: -10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="dropdown-menu text-center w-100"
style={{ display: "block", zIndex: 4000, top: "100%", marginTop: "0.25rem" }}
>
<li>
<button
type="button"
className={`dropdown-item ${selectedCategoryId === "all" ? "active" : ""}`}
onClick={() => { setSelectedCategoryId("all"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Todos
</button>
</li>
{role !== 1 && (
<> <>
<Dropdown.Item eventKey="watched" active={selectedCategoryId === 'watched'}>Vistos</Dropdown.Item> <li>
<Dropdown.Item eventKey="unwatched" active={selectedCategoryId === 'unwatched'}>Não vistos</Dropdown.Item> <button
type="button"
className={`dropdown-item ${selectedCategoryId === "watched" ? "active" : ""}`}
onClick={() => { setSelectedCategoryId("watched"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Vistos
</button>
</li>
<li>
<button
type="button"
className={`dropdown-item ${selectedCategoryId === "unwatched" ? "active" : ""}`}
onClick={() => { setSelectedCategoryId("unwatched"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Não vistos
</button>
</li>
</> </>
)} )}
{isAdmin === true && (
{role === 1 && (
<> <>
<Dropdown.Item eventKey="active" active={selectedCategoryId === 'active'}>Ativos</Dropdown.Item> <li>
<Dropdown.Item eventKey="inactive" active={selectedCategoryId === 'inactive'}>Inativos</Dropdown.Item> <button
type="button"
className={`dropdown-item ${selectedCategoryId === "active" ? "active" : ""}`}
onClick={() => { setSelectedCategoryId("active"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Ativos
</button>
</li>
<li>
<button
type="button"
className={`dropdown-item ${selectedCategoryId === "inactive" ? "active" : ""}`}
onClick={() => { setSelectedCategoryId("inactive"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Inativos
</button>
</li>
</> </>
)} )}
{categories.map((category) => ( {categories.map((category) => (
<Dropdown.Item key={category.id} eventKey={String(category.id)} active={selectedCategoryId === String(category.id)}>{category.name}</Dropdown.Item> <li key={category.id}>
<button
type="button"
className={`dropdown-item ${selectedCategoryId === String(category.id) ? "active" : ""}`}
onClick={() => { setSelectedCategoryId(String(category.id)); setCurrentPage(1); setShowFilterDropdown(false); }}
>
{category.name}
</button>
</li>
))} ))}
</Dropdown.Menu> </motion.ul>
</Dropdown> )}
</AnimatePresence>
</div>
</div> </div>
{isAdmin && ( {role === 1 && (
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4"> <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> <Link to="/admin/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" /> Adicionar vídeo</Link>
</div> </div>
@@ -200,7 +223,7 @@ export default function Videos() {
<div className="col-12 col-sm-6 col-lg-4 p-2"> <div className="col-12 col-sm-6 col-lg-4 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}> <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(', ')} > <div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && ( {role === 1 && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link> <Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)} )}
<img <img

View File

@@ -132,7 +132,6 @@ export default function Workshop() {
return ( return (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center"> <div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar dados do workshop...</span>
</div> </div>
) )
} }

View File

@@ -5,27 +5,25 @@ import { CgSpinner } from "react-icons/cg";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { LuPlus, LuCalendar, LuClock3, LuSettings2 } from "react-icons/lu"; import { LuPlus, LuCalendar, LuClock3, LuSettings2 } from "react-icons/lu";
import { useGetWorkshops } from "../../../hooks/useGetWorkshops"; import { useGetWorkshops } from "../../../hooks/useGetWorkshops";
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser";
import { useDebounce } from "../../../hooks/useDebounce"; import { useDebounce } from "../../../hooks/useDebounce";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { Dropdown, Form, Pagination } from "react-bootstrap"; import { Form, Pagination } from "react-bootstrap";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton"; import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { AnimatePresence, motion } from "framer-motion";
export default function Workshops() { 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 [loading, setLoading] = useState(true);
const [selectedWorkshopStatus, setSelectedWorkshopStatus] = useState<string>("pending"); const [selectedWorkshopStatus, setSelectedWorkshopStatus] = useState<string>("pending");
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
const { getWorkshops } = useGetWorkshops(); const { getWorkshops } = useGetWorkshops();
const [workshops, setWorkshops] = useState<Workshop[]>([]); const [workshops, setWorkshops] = useState<Workshop[]>([]);
const { getCurrentUser } = useGetCurrentUser();
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce(search, 500); const debouncedSearch = useDebounce(search, 500);
const [lastPage, setLastPage] = useState(1); const [lastPage, setLastPage] = useState(1);
const [loadingWorkshops, setLoadingWorkshops] = useState(false); const [loadingWorkshops, setLoadingWorkshops] = useState(false);
const [role, setRole] = useState(0);
const [userId, setUserId] = useState(0);
/* const [searchLoading, setSearchLoading] = useState(false); */ /* const [searchLoading, setSearchLoading] = useState(false); */
useEffect(() => { useEffect(() => {
@@ -33,9 +31,6 @@ export default function Workshops() {
setLoadingWorkshops(true); setLoadingWorkshops(true);
try { try {
const currentUserData = await getCurrentUser();
setCurrentUserData(currentUserData.data);
const workshopsData = await getWorkshops({ const workshopsData = await getWorkshops({
page: currentPage, page: currentPage,
search: debouncedSearch, search: debouncedSearch,
@@ -44,6 +39,8 @@ export default function Workshops() {
if ("workshops" in workshopsData) { if ("workshops" in workshopsData) {
setWorkshops(workshopsData.workshops); setWorkshops(workshopsData.workshops);
setUserId(workshopsData.userId);
setRole(workshopsData.role);
setLastPage(workshopsData.meta.last_page); setLastPage(workshopsData.meta.last_page);
setCurrentPage(workshopsData.meta.current_page); setCurrentPage(workshopsData.meta.current_page);
setLoadingWorkshops(false); setLoadingWorkshops(false);
@@ -149,7 +146,6 @@ export default function Workshops() {
return ( return (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center"> <div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar workshops...</span>
</div> </div>
) )
} }
@@ -167,32 +163,72 @@ export default function Workshops() {
</div> </div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4"> <div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
{/* <label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label> */} {/* <label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label> */}
<Dropdown className="flex-grow-1" onSelect={(value) => { <div
setCurrentPage(1); className="btn-group flex-grow-1 position-relative"
if (value) setSelectedWorkshopStatus(value); onMouseEnter={() => setShowFilterDropdown(true)}
}}> onMouseLeave={() => setShowFilterDropdown(false)}
<Dropdown.Toggle variant="outline-secondary" className="w-100 text-center" > >
<button type="button" className="btn btn-outline-secondary w-100 dropdown-toggle text-center">
<LuSettings2 /> {selectedWorkshopStatus === 'pending' ? 'Agendados' : selectedWorkshopStatus === 'inscrito' ? 'Inscrito' : selectedWorkshopStatus === 'realized' ? 'Realizados' : 'Cancelados'} <LuSettings2 /> {selectedWorkshopStatus === 'pending' ? 'Agendados' : selectedWorkshopStatus === 'inscrito' ? 'Inscrito' : selectedWorkshopStatus === 'realized' ? 'Realizados' : 'Cancelados'}
</Dropdown.Toggle> </button>
<Dropdown.Menu className="text-center w-100" >
<Dropdown.Item eventKey="pending" active={selectedWorkshopStatus === 'pending'}> <AnimatePresence>
{showFilterDropdown && (
<motion.ul
initial={{ opacity: 0, y: -10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="dropdown-menu text-center w-100"
style={{ display: "block", zIndex: 4000, top: "100%", marginTop: "0.25rem" }}
>
<li>
<button
type="button"
className={`dropdown-item ${selectedWorkshopStatus === "pending" ? "active" : ""}`}
onClick={() => { setSelectedWorkshopStatus("pending"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Agendados Agendados
</Dropdown.Item> </button>
{!isAdmin && ( </li>
<Dropdown.Item eventKey="inscrito" active={selectedWorkshopStatus === 'inscrito'}>
{role !== 1 && (
<li>
<button
type="button"
className={`dropdown-item ${selectedWorkshopStatus === "inscrito" ? "active" : ""}`}
onClick={() => { setSelectedWorkshopStatus("inscrito"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Inscrito Inscrito
</Dropdown.Item> </button>
</li>
)} )}
<Dropdown.Item eventKey="realized" active={selectedWorkshopStatus === 'realized'}>
<li>
<button
type="button"
className={`dropdown-item ${selectedWorkshopStatus === "realized" ? "active" : ""}`}
onClick={() => { setSelectedWorkshopStatus("realized"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Realizados Realizados
</Dropdown.Item> </button>
<Dropdown.Item eventKey="canceled" active={selectedWorkshopStatus === 'canceled'}> </li>
<li>
<button
type="button"
className={`dropdown-item ${selectedWorkshopStatus === "canceled" ? "active" : ""}`}
onClick={() => { setSelectedWorkshopStatus("canceled"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Cancelados Cancelados
</Dropdown.Item> </button>
</Dropdown.Menu> </li>
</Dropdown> </motion.ul>
)}
</AnimatePresence>
</div> </div>
{isAdmin ? ( </div>
{role === 1 ? (
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4" style={{ minHeight: '40px' }}> <div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4" style={{ minHeight: '40px' }}>
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link> <Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
</div> </div>
@@ -217,6 +253,11 @@ export default function Workshops() {
style={imageSkeletonFadeStyle} style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad} onLoad={onImageSkeletonLoad}
/> />
{(workshop.status === "realized" || workshop.status === "canceled") && (
<span className={`${styles.workshopStatus} position-absolute top-0 end-0 m-2 badge ${workshop.status === "realized" ? "bg-success" : "bg-danger"}`}>
{workshop.status === "realized" ? "Realizado" : "Cancelado"}
</span>
)}
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2"> <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.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> <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>
@@ -231,21 +272,11 @@ export default function Workshops() {
</div> </div>
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3"> <div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
{workshop.status === "realized" ? ( {workshop.status === "realized" || workshop.status === "canceled" ? (
<> role === 1 && (
<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> <Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
)} )
</> ) : workshop.status === "pending" && role !== 1 && workshop.users.some((u: User) => u.id === userId) ? (
) : workshop.status === "canceled" ? (
<>
<span className="text-danger fw-bold text-center py-2 mb-0">Workshop cancelado</span>
{isAdmin && (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
)}
</>
) : workshop.status === "pending" && currentUserData && workshop.users.some((u: User) => u.id === currentUserData.id) ? (
<> <>
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link> <Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { <button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => {
@@ -258,9 +289,9 @@ export default function Workshops() {
Anular inscrição Anular inscrição
</button> </button>
</> </>
) : isAdmin && workshop.status === "pending" ? ( ) : role === 1 && workshop.status === "pending" ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link> <Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : workshop.status === "pending" && !isAdmin ? ( ) : workshop.status === "pending" && role !== 1 ? (
<> <>
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link> <Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { <button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => {

View File

@@ -135,6 +135,10 @@
color: var(--text-primary-color); color: var(--text-primary-color);
} }
.workshopStatus {
z-index: 2;
}
.animateSpin{ .animateSpin{
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { ApiErrorResponse, LoginResponse, User } from "../../../types"; import type { ApiErrorResponse, LoginResponse } from "../../../types";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
@@ -48,7 +48,6 @@ export default function Login() {
if (response.ok) { if (response.ok) {
localStorage.setItem("token", data.access_token as string); localStorage.setItem("token", data.access_token as string);
localStorage.setItem("user", JSON.stringify(data.user as User));
navigate("/dashboard"); navigate("/dashboard");
} else { } else {
setError(data as ApiErrorResponse); setError(data as ApiErrorResponse);

View File

@@ -7,6 +7,9 @@ export default function Logout() {
useEffect(() => { useEffect(() => {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("videosWatched");
localStorage.removeItem("videosCount");
navigate("/login", { replace: true }); /* Replace é para evitar que o usuário possa voltar para a página de logout */ navigate("/login", { replace: true }); /* Replace é para evitar que o usuário possa voltar para a página de logout */
}, [navigate]); }, [navigate]);

View File

@@ -73,6 +73,8 @@ export type Video = {
is_active: boolean; is_active: boolean;
order: number; order: number;
watched: boolean; watched: boolean;
users: User[];
role: number;
} }
export type NextVideosResponse = { export type NextVideosResponse = {
@@ -84,6 +86,16 @@ export type UpdateVideoResponse = {
data?: Video; data?: Video;
} }
export type DashboardResponse = {
videos: Video[];
videosWatched: number;
videosCount: number;
workshops: Workshop[];
workshopsInscribed: number;
workshopsCount: number;
role: number;
}
export type GetVideosResponse = { export type GetVideosResponse = {
message?: string; message?: string;
data?: Video[]; data?: Video[];
@@ -112,6 +124,7 @@ export type Workshop = {
time_end: string; time_end: string;
is_active: boolean; is_active: boolean;
users: User[]; users: User[];
role: number;
} }
export type NextWorkshopsResponse = { export type NextWorkshopsResponse = {

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Video;
use App\Models\Workshop;
class DashboardController extends Controller
{
public function index()
{
$user = auth()->user();
if (!$user) {
return response()->json([
'message' => 'Utilizador não autenticado',
'data' => null,
'errors' => null,
], 404);
}
$userId = $user->id;
$role = $user->role_id;
$videos = Video::select('id', 'title', 'thumbnail', 'is_active', 'order')
->where('is_active', true)
->whereDoesntHave('views', function ($q) use ($user) {
$q->where('user_id', $user->id);
})
->orderBy('order', 'asc')
->limit(3)
->get()
->map(function ($video) {
return [
'id' => $video->id,
'title' => $video->title,
'thumbnail' => $video->thumbnail,
'is_active' => $video->is_active,
'watched' => false,
];
});
$videosCount = Video::select('id')->where('is_active', true)->count();
$videosWatched = Video::select('id')
->where('is_active', true)
->whereHas('views', function ($query) use ($user) {
$query->where('user_id', $user->id);
})->count();
$workshops = Workshop::select(['id', 'title', 'image', 'date', 'time_start', 'time_end', 'status'])
->with(['users:id'])
->where('status', 'pending')
->orderBy('date', 'asc')
->orderBy('time_start', 'asc')
->limit(3)
->get()
->map(function ($workshop) {
return [
'id' => $workshop->id,
'title' => $workshop->title,
'image' => $workshop->image,
'date' => $workshop->date,
'time_start' => $workshop->time_start,
'time_end' => $workshop->time_end,
'status' => $workshop->status,
'users' => $workshop->users->pluck('id'),
];
});
$workshopsInscribed = Workshop::select('id')
->where('status', 'pending')
->whereHas('users', function ($query) use ($user) {
$query->where('user_id', $user->id);
})->count();
$workshopsCount = Workshop::select('id')->where('status', 'pending')->count();
return response()->json([
'videos' => $videos,
'videosWatched' => $videosWatched,
'videosCount' => $videosCount,
'workshops' => $workshops,
'workshopsCount' => $workshopsCount,
'workshopsInscribed' => $workshopsInscribed,
'userId' => $userId,
'role' => $role,
], 200);
}
}

View File

@@ -7,6 +7,8 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Http\Requests\CreateUserRequest; use App\Http\Requests\CreateUserRequest;
use App\Http\Requests\UpdateUserRequest; use App\Http\Requests\UpdateUserRequest;
use App\Models\Workshop;
use App\Models\Video;
class UserController extends Controller class UserController extends Controller
{ {
@@ -22,7 +24,9 @@ class UserController extends Controller
], 404); ], 404);
} }
if ($user->role_id === 1) { $role = $user->role_id;
if ($role === 1) {
$search = trim((string) $request->query('search', '')); $search = trim((string) $request->query('search', ''));
$filter = $request->query('filter', 'all'); $filter = $request->query('filter', 'all');
$usersQuery = User::query(); $usersQuery = User::query();
@@ -45,6 +49,7 @@ class UserController extends Controller
return response()->json([ return response()->json([
'message' => 'Utilizadores obtidos com sucesso', 'message' => 'Utilizadores obtidos com sucesso',
'data' => $users, 'data' => $users,
'role' => $role,
'errors' => null, 'errors' => null,
], 200); ], 200);
} else { } else {
@@ -58,6 +63,7 @@ class UserController extends Controller
public function getUser($id) public function getUser($id)
{ {
$user = User::find($id); $user = User::find($id);
if (!$user) { if (!$user) {
@@ -68,10 +74,116 @@ class UserController extends Controller
], 404); ], 404);
} }
$videosWatched = Video::select('id')
->whereHas('views', function ($query) use ($user) {
$query->where('user_id', $user->id);
})->count();
$videosCount = Video::select('id')->where('is_active', true)->count();
$workshopsCount = Workshop::select('id')->where('status', 'pending')->count();
$workshopsInscribed = Workshop::select('id')
->where('status', 'pending')
->whereHas('users', function ($query) use ($user) {
$query->where('users.id', $user->id);
})->count();
$nextWorkshops = Workshop::select('id', 'title', 'image', 'date', 'time_start', 'time_end', 'status')->with('users:id')->where('status', 'pending')->orderBy('date', 'asc')->orderBy('time_start', 'asc')->limit(3)->get();
$workshopsParticipated = Workshop::select('id', 'title', 'image', 'date', 'time_start', 'time_end', 'status')->with('users:id')->whereHas('users', function ($query) use ($user) {
$query->where('users.id', $user->id);
})->get();
return response()->json([ return response()->json([
'message' => 'Utilizador obtido com sucesso', 'message' => 'Utilizador obtido com sucesso',
'data' => $user, 'data' => $user,
'errors' => null, 'errors' => null,
'videosWatched' => $videosWatched,
'videosCount' => $videosCount,
'workshopsInscribed' => $workshopsInscribed,
'nextWorkshops' => $nextWorkshops,
'workshopsCount' => $workshopsCount,
'workshopsParticipated' => $workshopsParticipated,
], 200);
}
public function profile()
{
$user = auth()->user();
if (!$user) {
return response()->json([
'message' => 'Utilizador não autenticado',
'data' => null,
'errors' => null,
], 404);
}
$role = $user->role_id;
$userId = $user->id;
if ($role === 1) {
$nextWorkshops = Workshop::select('id', 'title', 'image', 'date', 'time_start', 'time_end', 'status')->where('status', 'pending')->orderBy('date', 'asc')->orderBy('time_start', 'asc')->limit(3)->get();
$workshopsCount = Workshop::select('id')->where('status', 'pending')->count();
$videos = Video::select('id', 'title', 'thumbnail', 'is_active', 'order')->where('is_active', true)->orderBy('order', 'asc')->limit(3)->get()->map(function ($video) {
return [
'id' => $video->id,
'title' => $video->title,
'thumbnail' => $video->thumbnail,
'is_active' => $video->is_active,
'order' => $video->order,
];
});
$videosCount = Video::select('id')->where('is_active', true)->count();
} else {
$videos = Video::select('id', 'title', 'thumbnail', 'is_active', 'order')
->where('is_active', true)
->whereDoesntHave('views', function ($q) use ($user) {
$q->where('user_id', $user->id);
})
->orderBy('order', 'asc')
->limit(3)
->get()
->map(function ($video) {
return [
'id' => $video->id,
'title' => $video->title,
'thumbnail' => $video->thumbnail,
'is_active' => $video->is_active,
'watched' => false,
];
});
$videosCount = Video::select('id')->where('is_active', true)->count();
$videosWatched = Video::select('id')
->where('is_active', true)
->whereHas('views', function ($query) use ($user) {
$query->where('user_id', $user->id);
})->count();
$workshopsInscribed = Workshop::select('id', 'title', 'image', 'date', 'time_start', 'time_end', 'status')->with('users:id')->where('status', 'pending')->whereHas('users', function ($query) use ($user) {
$query->where('users.id', $user->id);
})->get();
$workshopsCount = Workshop::select('id')->where('status', 'pending')->count();
}
return response()->json([
'message' => 'Utilizador obtido com sucesso',
'data' => $user,
'videos' => $videos,
'videosCount' => $videosCount ?? 0,
'videosWatched' => $videosWatched ?? 0,
'workshopsInscribed' => $workshopsInscribed ?? [],
'workshopsCount' => $workshopsCount ?? 0,
'nextWorkshops' => $nextWorkshops ?? [],
'userId' => $userId,
'role' => $role,
'errors' => null,
], 200); ], 200);
} }
@@ -169,7 +281,7 @@ class UserController extends Controller
$userUpdated = User::find($id); $userUpdated = User::find($id);
return response()->json([ return response()->json([
'message' => 'Utilizador atualizado com sucesso', 'message' => 'Dados atualizados com sucesso',
'data' => $userUpdated, 'data' => $userUpdated,
'errors' => null, 'errors' => null,
], 201); ], 201);

View File

@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
use App\Http\Requests\CreateVideoRequest; use App\Http\Requests\CreateVideoRequest;
use App\Http\Requests\UpdateVideoRequest; use App\Http\Requests\UpdateVideoRequest;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use App\Models\Category;
class VideosController extends Controller class VideosController extends Controller
{ {
@@ -33,6 +34,9 @@ class VideosController extends Controller
], 401); ], 401);
} }
$userId = $user->id;
$role = $user->role_id;
$search = trim((string) $request->query('search', '')); $search = trim((string) $request->query('search', ''));
$categoryId = $request->query('category'); $categoryId = $request->query('category');
$watched = $request->query('watched'); $watched = $request->query('watched');
@@ -100,6 +104,12 @@ class VideosController extends Controller
]; ];
}); });
$categories = Category::select('id', 'name')
->where('is_active', true)
->whereHas('videos')
->orderBy('name', 'asc')
->get();
return response()->json([ return response()->json([
'message' => 'Vídeos obtidos com sucesso', 'message' => 'Vídeos obtidos com sucesso',
'data' => $query->items(), 'data' => $query->items(),
@@ -109,6 +119,9 @@ class VideosController extends Controller
'per_page' => $query->perPage(), 'per_page' => $query->perPage(),
'total' => $query->total(), 'total' => $query->total(),
], ],
'userId' => $userId,
'role' => $role,
'categories' => $categories,
]); ]);
} }
@@ -155,6 +168,7 @@ class VideosController extends Controller
]); ]);
} }
public function search(Request $request) public function search(Request $request)
{ {
$user = auth()->user(); $user = auth()->user();
@@ -319,9 +333,7 @@ class VideosController extends Controller
'order' => $validated['order'], 'order' => $validated['order'],
]); ]);
if ($request->filled('category_ids')) { $video->categories()->sync($request->input('category_ids', []));
$video->categories()->sync($request->input('category_ids'));
}
$baseUrl = $request->getSchemeAndHttpHost(); $baseUrl = $request->getSchemeAndHttpHost();
@@ -386,9 +398,7 @@ class VideosController extends Controller
'order' => $validated['order'] ?? $videoToUpdate->order, 'order' => $validated['order'] ?? $videoToUpdate->order,
]); ]);
if ($request->has('category_ids')) { $videoToUpdate->categories()->sync($request->input('category_ids', []));
$videoToUpdate->categories()->sync($request->input('category_ids'));
}
return response()->json([ return response()->json([
'message' => 'Dados do vídeo atualizados com sucesso', 'message' => 'Dados do vídeo atualizados com sucesso',

View File

@@ -22,6 +22,8 @@ class WorkshopsController extends Controller
], 404); ], 404);
} }
$role = $user->role_id;
$search = trim((string) $request->query('search', '')); $search = trim((string) $request->query('search', ''));
$status = trim((string) $request->query('status', '')); $status = trim((string) $request->query('status', ''));
$perPage = $request->query('per_page', 9); $perPage = $request->query('per_page', 9);
@@ -73,6 +75,8 @@ class WorkshopsController extends Controller
'per_page' => $query->perPage(), 'per_page' => $query->perPage(),
'total' => $query->total(), 'total' => $query->total(),
], ],
'userId' => $user->id,
'role' => $role,
]); ]);
} }
@@ -145,6 +149,8 @@ class WorkshopsController extends Controller
], 401); ], 401);
} }
$role = $user->role_id;
$workshops = Workshop::select(['id', 'title', 'image', 'date', 'time_start', 'time_end', 'status']) $workshops = Workshop::select(['id', 'title', 'image', 'date', 'time_start', 'time_end', 'status'])
->with(['users:id']) ->with(['users:id'])
->where('status', 'pending') ->where('status', 'pending')
@@ -168,6 +174,7 @@ class WorkshopsController extends Controller
return response()->json([ return response()->json([
'message' => 'Dashboard workshops', 'message' => 'Dashboard workshops',
'data' => $workshops, 'data' => $workshops,
'role' => $role,
]); ]);
} }
@@ -175,6 +182,9 @@ class WorkshopsController extends Controller
{ {
$workshop = Workshop::with('users')->find($id); $workshop = Workshop::with('users')->find($id);
$user = auth()->user();
$role = $user->role_id;
if (!$workshop) { if (!$workshop) {
return response()->json([ return response()->json([
'message' => 'Workshop não encontrado', 'message' => 'Workshop não encontrado',
@@ -186,6 +196,7 @@ class WorkshopsController extends Controller
'message' => 'Workshop obtido com sucesso', 'message' => 'Workshop obtido com sucesso',
'data' => $workshop, 'data' => $workshop,
'errors' => null, 'errors' => null,
'role' => $role,
], 200); ], 200);
} }

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\VideosController;
use App\Http\Controllers\WorkshopsController; use App\Http\Controllers\WorkshopsController;
use App\Http\Middleware\JwtMiddleware; use App\Http\Middleware\JwtMiddleware;
use App\Http\Controllers\VideoViewController; use App\Http\Controllers\VideoViewController;
use App\Http\Controllers\DashboardController;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| API Routes | API Routes
@@ -29,7 +30,7 @@ Route::post('/contact', [ContactController::class, 'send']); //proteção da rot
Route::middleware([JwtMiddleware::class])->group(function () { Route::middleware([JwtMiddleware::class])->group(function () {
/* Rota protegida por middleware JwtMiddleware - Só os utilizadores autenticados podem aceder a esta rota */ /* Rota protegida por middleware JwtMiddleware - Só os utilizadores autenticados podem aceder a esta rota */
Route::patch('/profile/{id}', [UserController::class, 'update']); Route::patch('/profile/{id}', [UserController::class, 'update']);
Route::get('/profile/{id}', [UserController::class, 'getUser']); Route::get('/profile', [UserController::class, 'profile']);
Route::get('/videos', [VideosController::class, 'index']); Route::get('/videos', [VideosController::class, 'index']);
Route::get('/video/{id}', [VideosController::class, 'getVideo']); Route::get('/video/{id}', [VideosController::class, 'getVideo']);
@@ -51,6 +52,8 @@ Route::middleware([JwtMiddleware::class])->group(function () {
Route::post('/inscrever/{id}', [WorkshopsController::class, 'inscrever']); Route::post('/inscrever/{id}', [WorkshopsController::class, 'inscrever']);
Route::delete('/cancelar-inscricao/{id}', [WorkshopsController::class, 'cancelarInscricao']); Route::delete('/cancelar-inscricao/{id}', [WorkshopsController::class, 'cancelarInscricao']);
Route::get('/dashboard', [DashboardController::class, 'index']);
/* Para fazer a verificação do role_id no frontend das páginas do admin */ /* Para fazer a verificação do role_id no frontend das páginas do admin */
Route::get('/me', [AuthController::class, 'me']); Route::get('/me', [AuthController::class, 'me']);