feat: request optimizations
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
|
||||
async function getVerifyRole() {
|
||||
const fetchCurrentUser = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("http://127.0.0.1:8000/api/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
const currentUser = await getCurrentUser();
|
||||
|
||||
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");
|
||||
if (currentUser.data.role_id !== 1) {
|
||||
navigate("/dashboard", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setCheckingRole(false);
|
||||
navigate("/dashboard");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role_id !== 1) {
|
||||
navigate("/dashboard", { replace: true });
|
||||
}
|
||||
}, [user, navigate]);
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
if (!user || user.role_id !== 1) return null;
|
||||
if (loading) {
|
||||
return <div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
|
||||
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
|
||||
return (
|
||||
<Outlet />
|
||||
);
|
||||
|
||||
/* Criado o layout para o admin, o proximo passo é aninhar este layout em routes.tsx no frontend */
|
||||
}
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}, [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">
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 há workshops agendados</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
|
||||
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Home</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 && (
|
||||
</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 && (
|
||||
<>
|
||||
<Dropdown.Item eventKey="watched" active={selectedCategoryId === 'watched'}>Vistos</Dropdown.Item>
|
||||
<Dropdown.Item eventKey="unwatched" active={selectedCategoryId === 'unwatched'}>Não vistos</Dropdown.Item>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{isAdmin === true && (
|
||||
|
||||
{role === 1 && (
|
||||
<>
|
||||
<Dropdown.Item eventKey="active" active={selectedCategoryId === 'active'}>Ativos</Dropdown.Item>
|
||||
<Dropdown.Item eventKey="inactive" active={selectedCategoryId === 'inactive'}>Inativos</Dropdown.Item>
|
||||
<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) => (
|
||||
<Dropdown.Item key={category.id} eventKey={String(category.id)} active={selectedCategoryId === String(category.id)}>{category.name}</Dropdown.Item>
|
||||
<li key={category.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropdown-item ${selectedCategoryId === String(category.id) ? "active" : ""}`}
|
||||
onClick={() => { setSelectedCategoryId(String(category.id)); setCurrentPage(1); setShowFilterDropdown(false); }}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</motion.ul>
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'}>
|
||||
</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
|
||||
</Dropdown.Item>
|
||||
{!isAdmin && (
|
||||
<Dropdown.Item eventKey="inscrito" active={selectedWorkshopStatus === 'inscrito'}>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{role !== 1 && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropdown-item ${selectedWorkshopStatus === "inscrito" ? "active" : ""}`}
|
||||
onClick={() => { setSelectedWorkshopStatus("inscrito"); setCurrentPage(1); setShowFilterDropdown(false); }}
|
||||
>
|
||||
Inscrito
|
||||
</Dropdown.Item>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
<Dropdown.Item eventKey="realized" active={selectedWorkshopStatus === 'realized'}>
|
||||
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropdown-item ${selectedWorkshopStatus === "realized" ? "active" : ""}`}
|
||||
onClick={() => { setSelectedWorkshopStatus("realized"); setCurrentPage(1); setShowFilterDropdown(false); }}
|
||||
>
|
||||
Realizados
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item eventKey="canceled" active={selectedWorkshopStatus === 'canceled'}>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropdown-item ${selectedWorkshopStatus === "canceled" ? "active" : ""}`}
|
||||
onClick={() => { setSelectedWorkshopStatus("canceled"); setCurrentPage(1); setShowFilterDropdown(false); }}
|
||||
>
|
||||
Cancelados
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</button>
|
||||
</li>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
</div>
|
||||
{role === 1 ? (
|
||||
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4" style={{ minHeight: '40px' }}>
|
||||
<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 && (
|
||||
{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 === "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 === "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={() => {
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.workshopStatus {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.animateSpin{
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user