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

View File

@@ -61,7 +61,12 @@
.logo {
width: 180px;
aspect-ratio: 3 / 1;
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,15 +16,10 @@ export default function CreateVideo() {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [category_ids, setCategoryIds] = useState<string[]>([]);
const [checkingRole, setCheckingRole] = useState(true);
const [creating, setCreating] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [order, setOrder] = useState<number>(0);
setTimeout(() => {
setCheckingRole(false);
}, 2000);
useEffect(() => {
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 (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}>

View File

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

View File

@@ -117,6 +117,8 @@ export default function editVideo() {
category_ids.forEach((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
// quando a rota aceita PATCH (method spoofing)
@@ -241,7 +243,6 @@ export default function editVideo() {
{loading ? (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar dados do vídeo...</span>
</div>
) : (
<div className={`${styles.container} p-3 p-xl-0`}>

View File

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

View File

@@ -1,31 +1,26 @@
import { useEffect, useState } from "react";
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 { 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 Swal from "sweetalert2";
import { useGetWorkshops } from "../../../../hooks/useGetWorkshops";
export default function User() {
const { id } = useParams();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [formEdit, setFormEdit] = useState<boolean>(false);
const [workshops, setWorkshops] = useState<Workshop[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const { getWorkshops } = useGetWorkshops();
const navigate = useNavigate();
const [videosWatched, setVideosWatched] = useState(0);
const [videosCount, setVideosCount] = useState(0);
const [workshopsInscribed, setWorkshopsInscribed] = useState(0);
const [workshopsParticipated, setWorkshopsParticipated] = useState<Workshop[]>([]);
useEffect(() => {
const fetchAll = async () => {
const [workshopsData] = await Promise.all([
getWorkshops(),
]);
setWorkshops(workshopsData as Workshop[]);
getUser();
};
fetchAll();
getUser();
}, [id]);
async function getUser() {
@@ -44,6 +39,10 @@ export default function User() {
if (response.ok) {
setUser(data.data);
setVideosWatched(data.videosWatched);
setVideosCount(data.videosCount);
setWorkshopsInscribed(data.workshopsInscribed);
setWorkshopsParticipated(data.workshopsParticipated as Workshop[]);
} else {
setError(data as ApiErrorResponse);
setUser(null);
@@ -166,11 +165,11 @@ export default function User() {
</div>
{ user ? (
{user ? (
<div className="my-3">
<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={`${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">
@@ -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="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-primary-color-opacity)" }}>Vídeos assistidos</span>
<span className="fs-2 fw-bold text-white">0/{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
<span className="fs-2 fw-bold text-white">{videosWatched}/{videosCount}</span>
</div>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Workshops inscrito</span>
<span className="fs-2 fw-bold text-white">{workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === user?.id)).length}</span>
<span className="fs-2 fw-bold text-white">{workshopsInscribed}</span>
</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 className="mt-5">

View File

@@ -9,6 +9,66 @@
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{
animation: spin 1s linear infinite;
}
@@ -71,7 +131,7 @@
.userVideosWatched{
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
background-color: var(--bg-neutral-color);
}
.closeFormButton{

View File

@@ -1,26 +1,21 @@
import { useEffect, useState } from "react";
import { useDebounce } from "../../../../hooks/useDebounce";
import type { ApiErrorResponse, User } from "../../../../types";
import { Link, Navigate } from "react-router";
import { Link } from "react-router";
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 { LuPlus } from "react-icons/lu";
import { Dropdown, Form, Pagination, Table } from "react-bootstrap";
import { Form, Pagination, Table } from "react-bootstrap";
import { AnimatePresence, motion } from "framer-motion";
export default function Users() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string);
if (user.role_id !== 1) {
return <Navigate to="/dashboard" />;
}
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState<string>("");
const [selectedUsers, setSelectedUsers] = useState<string>("all");
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [listTotal, setListTotal] = useState(0);
@@ -68,8 +63,8 @@ export default function Users() {
if (loading) {
return (
<div className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<div className="d-flex flex-column text-center mt-5">
<CgSpinner className={`${styles.animateSpin} mx-auto text-2xl fs-3`} />
</div>
)
}
@@ -96,22 +91,56 @@ export default function Users() {
</div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
<Dropdown className="flex-grow-1" onSelect={(value) => {
if (value) {
setSelectedUsers(value);
setCurrentPage(1);
setLoadingUsers(true);
}
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100">
<div
className="btn-group flex-grow-1 position-relative"
onMouseEnter={() => setShowFilterDropdown(true)}
onMouseLeave={() => setShowFilterDropdown(false)}
>
<button type="button" className="btn btn-outline-secondary w-100 dropdown-toggle">
<LuSettings2 /> {selectedUsers === 'all' ? 'Todos' : selectedUsers === 'admin' ? 'Administradores' : 'Utilizadores'}
</Dropdown.Toggle>
<Dropdown.Menu className="text-center w-100">
<Dropdown.Item eventKey="all" active={selectedUsers === 'all'}>Todos</Dropdown.Item>
<Dropdown.Item eventKey="admin" active={selectedUsers === 'admin'}>Administradores</Dropdown.Item>
<Dropdown.Item eventKey="user" active={selectedUsers === 'user'}>Utilizadores</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</button>
<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 ${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 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>
)
}

View File

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

View File

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

View File

@@ -1,45 +1,74 @@
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 { LuCheck, LuKeyRound, LuPencil, LuX } from "react-icons/lu";
import { useGetWorkshops } from "../../../hooks/useGetWorkshops";
import { LuCheck, LuKeyRound, LuPencil, LuX, LuArrowUpRight, LuClock3, LuCalendar } from "react-icons/lu";
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() {
const [user, setUser] = useState<User | null>(JSON.parse(localStorage.getItem("user") as unknown as string));
const [error, setError] = useState<ApiErrorResponse | null>(null);
const isAdmin = user?.role_id === 1;
/* const [loading, setLoading] = useState(true); */
const [role, setRole] = useState(0);
const [userId, setUserId] = useState(0);
const [loading, setLoading] = useState(true);
const [formEdit, setFormEdit] = useState<boolean>(false);
const [formEditPassword, setFormEditPassword] = useState<boolean>(false);
const [messageUpdateUser, setMessageUpdateUser] = useState<string>("");
const [messageUpdatePassword, setMessageUpdatePassword] = useState<string>("");
const { getWorkshops } = useGetWorkshops();
const [userData, setUserData] = useState<User | null>(null);
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(() => {
const fetchAll = async () => {
const [workshopsData] = await Promise.all([
getWorkshops(),
]);
setWorkshops(workshopsData as Workshop[]);
};
fetchAll();
getProfile();
}, []);
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>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const user_id = user?.id;
const user_id = userData?.id;
const passwordAtual = formData.get("passwordAtual");
const novaPassword = formData.get("novaPassword");
const confirmarPassword = formData.get("confirmarPassword");
if (novaPassword !== confirmarPassword) {
setError({
message: "As passwords não coincidem",
data: null,
errors: {},
Swal.fire({
title: "As passwords não coincidem",
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
return;
}
@@ -64,24 +93,29 @@ export default function Profile() {
const data = await response.json();
if (response.ok) {
setMessageUpdatePassword("Palavra-passe atualizada com sucesso");
setTimeout(() => {
setMessageUpdatePassword("");
}, 3000)
Swal.fire({
title: data.message as string,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
setFormEditPassword(false);
setError(null);
} else {
setMessageUpdatePassword("");
setError(data as ApiErrorResponse);
Swal.fire({
title: data.message as string,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
async function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const user_id = user?.id;
const name = formData.get("name");
const email = formData.get("email");
const user_id = userData?.id;
const name = formData.get("name") || userData?.name;
const email = formData.get("email") || userData?.email;
const payload = {
user_id: user_id,
@@ -102,32 +136,63 @@ export default function Profile() {
const data = await response.json();
if (response.ok) {
setUser(data.data);
setFormEdit(false);
setMessageUpdateUser(data.message as string);
setTimeout(() => {
setMessageUpdateUser("");
}, 3000)
setError(null);
Swal.fire({
title: data.message,
icon: 'success',
showConfirmButton: false,
showCloseButton: true,
});
getProfile();
} else {
setMessageUpdateUser("");
setError(data as ApiErrorResponse);
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
/* Cancelar inscrição num workshop */
async function cancelarInscricao(workshopId: number) {
const response = await fetch(`http://127.0.0.1:8000/api/cancelar-inscricao/${workshopId}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
/* { 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 (
<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`} />
<span>A carregar os seus dados...</span>
</div>
)
} */
);
}
return (
<div className="container">
@@ -140,43 +205,43 @@ export default function Profile() {
<div className="row justify-content-between">
<div className="col-12 d-flex text-start mt-xl-0 justify-content-between flex-grow-1 mb-3">
<div className="d-flex flex-column">
<span className="fw-bold fs-4 mt-1">{user?.name}</span>
<span className="fs-5 flex-grow-1">{user?.email}</span>
<span className="fw-bold fs-4 mt-1">{userData?.name}</span>
<span className="fs-5 flex-grow-1">{userData?.email}</span>
</div>
</div>
<div className="col-12 d-flex flex-column flex-md-row gap-0 gap-md-2 text-end align-items-start align-items-md-end mt-md-0 mx-auto mx-md-0 flex-wrap">
<span className="d-flex justify-content-center flex-wrap flex-sm-nowrap border border-secondary-subtle py-2 px-2 rounded-3 fs-6 fw-medium py-0" style={{ maxWidth: "fit-content", height: "fit-content" }}>Membro desde: <b className="fw-bold fs-6">{new Date(user?.created_at as string).toLocaleDateString('pt-PT')}</b></span>
<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">
<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
`} 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 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="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-primary-color-opacity)" }}>Vídeos ativos</span>
<span className="fs-2 fw-bold text-white">{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Vídeos ativos</span>
<span className="fs-2 fw-bold text-white">{videosCount}</span>
</div>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Workshops agendados</span>
<span className="fs-2 fw-bold text-white">{workshops.filter((workshop: Workshop) => workshop.status === "pending").length}</span>
<span className="fs-2 fw-bold text-white">{workshopsCount}</span>
</div>
</div>
) : (
<div className={`${styles.userVideosWatched} text-start d-flex flex-column h-100 gap-3 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-primary-color-opacity)" }}>Vídeos assistidos</span>
<span className="fs-2 fw-bold text-white">0/{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Vídeos assistidos</span>
<span className="fs-2 fw-bold text-white">{videosWatched}/{videosCount}</span>
</div>
<div className="d-flex flex-column">
<span className="fw-normal fs-6" style={{ color: "var(--bg-grey)" }}>Workshops inscrito</span>
<span className="fs-2 fw-bold text-white">{workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === user?.id)).length}</span>
<span className="fs-2 fw-bold text-white">{workshopsCount}</span>
</div>
</div>
)}
@@ -185,39 +250,23 @@ export default function Profile() {
</div>
</div>
{messageUpdateUser || messageUpdatePassword ? (
<div className="">
<div className="alert alert-success mt-4">
{messageUpdateUser || messageUpdatePassword}
</div>
</div>
) : null}
{formEdit ? (
<div className="my-3">
<div className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<span className={styles.title}>Editar dados</span>
<form method="patch" onSubmit={update} id="formEditUser">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<input type="hidden" name="user_id" value={userId} />
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label>
<input type="text" className="form-control py-2" id="name" name="name" defaultValue={user?.name} />
<input type="text" className="form-control py-2" id="name" name="name" defaultValue={userData?.name} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="email">Email</label>
<input type="email" className="form-control py-2" id="email" name="email" defaultValue={user?.email} />
<input type="email" className="form-control py-2" id="email" name="email" defaultValue={userData?.email} />
</div>
</div>
{error?.errors ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{error.errors.message || error.message}</p>
</div>
</div>
) : null}
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center flex-wrap" >
<button type="button" className={`${styles.closeFormButton}`} onClick={() => setFormEdit(false)}> Cancelar <LuX className={`${styles.icon} mb-1`} /></button>
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
@@ -225,12 +274,12 @@ export default function Profile() {
</form>
</div>
) : formEditPassword ? (
<div className="my-3">
<div className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<span className={styles.title}>Alterar password</span>
<form method="patch" onSubmit={updatePassword} id="formEditPassword">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<input type="hidden" name="user_id" value={userId} />
<div className="col-12 col-lg-4 text-start">
<label className="form-label fw-bold" htmlFor="passwordAtual">Password atual</label>
<input type="password" className="form-control py-2" id="passwordAtual" name="passwordAtual" />
@@ -245,14 +294,6 @@ export default function Profile() {
</div>
</div>
{error?.errors ? (
<div className="">
<div className="alert alert-danger mt-4">
<p>{error.errors.message || error.message}</p>
</div>
</div>
) : null}
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="button" className={`${styles.closeFormButton}`} onClick={() => setFormEditPassword(false)}> Cancelar <LuX className={`${styles.icon} mb-1`} /></button>
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
@@ -260,6 +301,141 @@ export default function Profile() {
</form>
</div>
) : 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>
);
}

View File

@@ -9,6 +9,11 @@
font-size: var(--size-font-title);
}
.subtitle {
color: var(--primary-contrast-color);
font-size: var(--size-font-subtitle);
}
.userCard{
border-radius: var(--border-radius);
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{
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 { LuPlus, LuSettings2 } 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 { useDebounce } from "../../../hooks/useDebounce";
import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { AnimatePresence, motion } from "framer-motion";
export default function Videos() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all");
const [isAdmin, setIsAdmin] = useState(false);
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
const { getVideos } = useGetVideos();
const [videos, setVideos] = useState<Video[]>([]);
const [search, setSearch] = useState<string>("");
@@ -25,49 +26,7 @@ export default function Videos() {
const [lastPage, setLastPage] = useState(1);
const [loadingVideos, setLoadingVideos] = useState(false);
const videosToShow = videos;
useEffect(() => {
getRole();
getCategories();
}, []);
async function getRole() {
const response = await fetch("http://127.0.0.1:8000/api/me", {
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([]);
}
}
const [role, setRole] = useState(0);
useEffect(() => {
const fetchVideos = async () => {
@@ -103,6 +62,8 @@ export default function Videos() {
setVideos(videosData.videos);
setLastPage(videosData.meta.last_page);
setCurrentPage(videosData.meta.current_page);
setRole(videosData.role);
setCategories(videosData.categories);
} else {
setVideos([]);
}
@@ -145,42 +106,104 @@ export default function Videos() {
</div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
<Dropdown className="flex-grow-1" onSelect={(value) => {
setCurrentPage(1);
if (value) {
setSelectedCategoryId(value);
}
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100">
<div
className="btn-group flex-grow-1 position-relative"
onMouseEnter={() => setShowFilterDropdown(true)}
onMouseLeave={() => setShowFilterDropdown(false)}
>
<button type="button" className="btn btn-outline-secondary w-100 dropdown-toggle">
<LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' :
selectedCategoryId === 'active' ? 'Ativos' :
selectedCategoryId === 'inactive' ? 'Inativos' :
selectedCategoryId === 'watched' ? 'Vistos' :
selectedCategoryId === 'unwatched' ? 'Não vistos' :
categories.find(c => String(c.id) === selectedCategoryId)?.name ?? 'Todos'}
</Dropdown.Toggle>
<Dropdown.Menu style={{ zIndex: 4000 }} className="text-center w-100">
<Dropdown.Item eventKey="all" active={selectedCategoryId === 'all'}>Todos</Dropdown.Item>
{!isAdmin && (
<>
<Dropdown.Item eventKey="watched" active={selectedCategoryId === 'watched'}>Vistos</Dropdown.Item>
<Dropdown.Item eventKey="unwatched" active={selectedCategoryId === 'unwatched'}>Não vistos</Dropdown.Item>
</>
</button>
<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 ${selectedCategoryId === "all" ? "active" : ""}`}
onClick={() => { setSelectedCategoryId("all"); setCurrentPage(1); setShowFilterDropdown(false); }}
>
Todos
</button>
</li>
{role !== 1 && (
<>
<li>
<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>
</>
)}
{role === 1 && (
<>
<li>
<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) => (
<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>
))}
</motion.ul>
)}
{isAdmin === true && (
<>
<Dropdown.Item eventKey="active" active={selectedCategoryId === 'active'}>Ativos</Dropdown.Item>
<Dropdown.Item eventKey="inactive" active={selectedCategoryId === 'inactive'}>Inativos</Dropdown.Item>
</>
)}
{categories.map((category) => (
<Dropdown.Item key={category.id} eventKey={String(category.id)} active={selectedCategoryId === String(category.id)}>{category.name}</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</AnimatePresence>
</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">
<Link to="/admin/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" /> Adicionar vídeo</Link>
</div>
@@ -200,7 +223,7 @@ export default function Videos() {
<div className="col-12 col-sm-6 col-lg-4 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`} key={video.id}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && (
{role === 1 && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<img

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, type FormEvent } from "react";
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 { CgSpinner } from "react-icons/cg";
@@ -48,7 +48,6 @@ export default function Login() {
if (response.ok) {
localStorage.setItem("token", data.access_token as string);
localStorage.setItem("user", JSON.stringify(data.user as User));
navigate("/dashboard");
} else {
setError(data as ApiErrorResponse);

View File

@@ -7,6 +7,9 @@ export default function Logout() {
useEffect(() => {
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]);

View File

@@ -21,20 +21,20 @@ export type ApiErrorResponse = {
}
export type User = {
id: number;
name: string;
email: string;
role_id: number;
password: string;
created_at: string;
updated_at: string;
allowed_access?: boolean;
pivot?: {
workshop_id: number;
user_id: number;
id: number;
name: string;
email: string;
role_id: number;
password: string;
created_at: string;
updated_at: string;
};
allowed_access?: boolean;
pivot?: {
workshop_id: number;
user_id: number;
created_at: string;
updated_at: string;
};
}
export type UpdateUserResponse = {
@@ -73,6 +73,8 @@ export type Video = {
is_active: boolean;
order: number;
watched: boolean;
users: User[];
role: number;
}
export type NextVideosResponse = {
@@ -84,6 +86,16 @@ export type UpdateVideoResponse = {
data?: Video;
}
export type DashboardResponse = {
videos: Video[];
videosWatched: number;
videosCount: number;
workshops: Workshop[];
workshopsInscribed: number;
workshopsCount: number;
role: number;
}
export type GetVideosResponse = {
message?: string;
data?: Video[];
@@ -112,6 +124,7 @@ export type Workshop = {
time_end: string;
is_active: boolean;
users: User[];
role: number;
}
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 App\Http\Requests\CreateUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Models\Workshop;
use App\Models\Video;
class UserController extends Controller
{
@@ -22,7 +24,9 @@ class UserController extends Controller
], 404);
}
if ($user->role_id === 1) {
$role = $user->role_id;
if ($role === 1) {
$search = trim((string) $request->query('search', ''));
$filter = $request->query('filter', 'all');
$usersQuery = User::query();
@@ -30,7 +34,7 @@ class UserController extends Controller
if ($search !== '') {
$usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
->orWhere('email', 'like', "%{$search}%");
});
}
@@ -45,6 +49,7 @@ class UserController extends Controller
return response()->json([
'message' => 'Utilizadores obtidos com sucesso',
'data' => $users,
'role' => $role,
'errors' => null,
], 200);
} else {
@@ -58,6 +63,7 @@ class UserController extends Controller
public function getUser($id)
{
$user = User::find($id);
if (!$user) {
@@ -68,10 +74,116 @@ class UserController extends Controller
], 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([
'message' => 'Utilizador obtido com sucesso',
'data' => $user,
'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);
}
@@ -169,7 +281,7 @@ class UserController extends Controller
$userUpdated = User::find($id);
return response()->json([
'message' => 'Utilizador atualizado com sucesso',
'message' => 'Dados atualizados com sucesso',
'data' => $userUpdated,
'errors' => null,
], 201);

View File

@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
use App\Http\Requests\CreateVideoRequest;
use App\Http\Requests\UpdateVideoRequest;
use Illuminate\Support\Facades\Storage;
use App\Models\Category;
class VideosController extends Controller
{
@@ -33,6 +34,9 @@ class VideosController extends Controller
], 401);
}
$userId = $user->id;
$role = $user->role_id;
$search = trim((string) $request->query('search', ''));
$categoryId = $request->query('category');
$watched = $request->query('watched');
@@ -43,7 +47,7 @@ class VideosController extends Controller
'categories:id,name',
'views' => function ($q) use ($user) {
$q->select('id', 'video_id', 'user_id')
->where('user_id', $user->id);
->where('user_id', $user->id);
}
])
@@ -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([
'message' => 'Vídeos obtidos com sucesso',
'data' => $query->items(),
@@ -109,6 +119,9 @@ class VideosController extends Controller
'per_page' => $query->perPage(),
'total' => $query->total(),
],
'userId' => $userId,
'role' => $role,
'categories' => $categories,
]);
}
@@ -155,6 +168,7 @@ class VideosController extends Controller
]);
}
public function search(Request $request)
{
$user = auth()->user();
@@ -174,7 +188,7 @@ class VideosController extends Controller
})
->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%")
->orWhere('tags', 'like', "%{$search}%");
->orWhere('tags', 'like', "%{$search}%");
})
->limit(20)
->get()
@@ -319,9 +333,7 @@ class VideosController extends Controller
'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();
@@ -386,9 +398,7 @@ class VideosController extends Controller
'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([
'message' => 'Dados do vídeo atualizados com sucesso',

View File

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

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\VideosController;
use App\Http\Controllers\WorkshopsController;
use App\Http\Middleware\JwtMiddleware;
use App\Http\Controllers\VideoViewController;
use App\Http\Controllers\DashboardController;
/*
|--------------------------------------------------------------------------
| API Routes
@@ -29,7 +30,7 @@ Route::post('/contact', [ContactController::class, 'send']); //proteção da rot
Route::middleware([JwtMiddleware::class])->group(function () {
/* Rota protegida por middleware JwtMiddleware - Só os utilizadores autenticados podem aceder a esta rota */
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('/video/{id}', [VideosController::class, 'getVideo']);
@@ -51,6 +52,8 @@ Route::middleware([JwtMiddleware::class])->group(function () {
Route::post('/inscrever/{id}', [WorkshopsController::class, 'inscrever']);
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 */
Route::get('/me', [AuthController::class, 'me']);