feat: videos navugation by order

This commit is contained in:
Xavier Oliveira
2026-05-29 17:59:35 +01:00
parent 0ec00467b3
commit 985f5ffb69
15 changed files with 306 additions and 91 deletions

View File

@@ -0,0 +1,23 @@
import { useMotionValue, useSpring } from "framer-motion";
import { useEffect, useState } from "react";
const AnimatedCounter = ({ value }: { value: number }) => {
const motionValue = useMotionValue(0);
const spring = useSpring(motionValue, { stiffness: 60, damping: 20 });
const [display, setDisplay] = useState(0);
useEffect(() => {
motionValue.set(value);
}, [value]);
useEffect(() => {
const unsubscribe = spring.on("change", (v) => {
setDisplay(Math.round(v));
});
return unsubscribe;
}, [spring]);
return <span>{display}</span>;
};
export default AnimatedCounter;

View File

@@ -0,0 +1,31 @@
import { useMotionValue, useSpring } from "framer-motion";
import { useEffect, useState } from "react";
import { ProgressBar } from "react-bootstrap";
const AnimatedProgressBar = ({ value, className }: { value: number; className?: string }) => {
const motionValue = useMotionValue(0);
const spring = useSpring(motionValue, { stiffness: 60, damping: 20 });
const [display, setDisplay] = useState(0);
useEffect(() => {
motionValue.set(value);
}, [value]);
useEffect(() => {
// ✅ subscribe mantém o state sincronizado com o spring
const unsubscribe = spring.on("change", (v) => {
setDisplay(Math.round(v));
});
return unsubscribe;
}, [spring]);
return (
<ProgressBar
className={className}
now={display}
label={`${display}%`}
/>
);
};
export default AnimatedProgressBar;

View File

@@ -2,10 +2,25 @@ import { useEffect } from 'react';
import '@n8n/chat/style.css'; import '@n8n/chat/style.css';
import { createChat } from '@n8n/chat'; import { createChat } from '@n8n/chat';
import './n8n-chat-theme.css'; import './n8n-chat-theme.css';
import { motion } from 'framer-motion';
export default function N8nChat() { export default function N8nChat() {
useEffect(() => { useEffect(() => {
const style = document.createElement('style'); const style = document.createElement('style');
style.innerHTML = `
/* 1. TORNAMOS O BOTÃO DA N8N TRANSPARENTE */
#n8n-chat .n8n-chat-toggle-button {
background-color: transparent !important;
background-image: none !important;
border: none !important;
box-shadow: none !important;
}
/* Garante que o ícone branco fica bem visível sobre a nossa nova sombra */
#n8n-chat .n8n-chat-toggle-button svg {
filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.2));
}
`;
document.head.appendChild(style); document.head.appendChild(style);
const app = createChat({ const app = createChat({
@@ -27,13 +42,56 @@ export default function N8nChat() {
return () => { return () => {
app.unmount(); app.unmount();
document.head.removeChild(style);
}; };
}, []); }, []);
return ( return (
<div style={{ position: 'relative' }}>
{/* 2. ESTA AGORA É A VERDADEIRA E ÚNICA FORMA DO BOTÃO */}
<motion.div
style={{
position: 'fixed',
bottom: '16px', // Ajustado ligeiramente para centrar com o ícone
right: '16px',
width: '76px', // Tamanho ideal para cobrir a área do chat
height: '76px',
// GRADIENTE RADIAL: O centro é a tua cor escura, que se funde suavemente com a cor primária e desaparece
background: 'radial-gradient(circle, #410002 0%, #B20112 45%, #C4534A 70%, rgba(196, 83, 74, 0) 100%)',
filter: 'blur(4px)', // Blur reduzido para não desmanchar o centro do botão, mantendo a borda super suave
zIndex: 9998,
pointerEvents: 'none',
}}
animate={{
// Rotação abstrata
rotate: [0, 360],
// A distorção líquida das bordas para dar o efeito AI
borderRadius: [
"44% 56% 62% 38% / 48% 42% 58% 52%",
"62% 38% 52% 48% / 55% 45% 55% 45%",
"44% 56% 62% 38% / 48% 42% 58% 52%"
],
// Pulsação de tamanho muito leve e fluida
scale: [0.96, 1.04, 0.96]
}}
transition={{
repeat: Infinity,
duration: 5,
ease: "easeInOut", // Mudei para easeInOut para tornar o movimento mais orgânico que o linear
}}
/>
{/* CONTAINER DO CHAT DA N8N */}
<div <div
id="n8n-chat" id="n8n-chat"
style={{ color: 'var(--text-primary-color)', textAlign: 'start'}} style={{
color: 'var(--text-primary-color)',
textAlign: 'start',
position: 'relative',
zIndex: 9999
}}
/> />
</div>
); );
} }

View File

@@ -17,8 +17,7 @@
--chat--message--border-radius: var(--border-radius); --chat--message--border-radius: var(--border-radius);
--chat--message--border-color: var(--shadow-primary); --chat--message--border-color: var(--shadow-primary);
--chat--input--send--button--color: var(--primary-color); --chat--input--send--button--color: var(--primary-color);
--chat--toggle--background: var(--primary-color); --chat--toggle--background: transparent;
--chat--toggle--border: 2px solid var(--primary-color);
} }
.chat-window-wrapper .chat-window-toggle { .chat-window-wrapper .chat-window-toggle {
@@ -29,8 +28,7 @@
} }
.chat-window-wrapper .chat-window-toggle:hover { .chat-window-wrapper .chat-window-toggle:hover {
background-color: var(--bg-grey) !important; background-color: transparent !important;
color: var(--text-primary-color) !important;
} }
.chat-layout .chat-header h1 { .chat-layout .chat-header h1 {

View File

@@ -15,7 +15,7 @@ import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton"; import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useGetCurrentUser } from "../../hooks/useGetCurrentUser"; import { useGetCurrentUser } from "../../hooks/useGetCurrentUser";
import AnimatedCounter from "../animatedNumberCount";
export default function Header() { export default function Header() {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
@@ -166,7 +166,7 @@ export default function Header() {
{role !== 1 && videosCount > 0 && ( {role !== 1 && videosCount > 0 && (
<div className="d-flex d-sm-none justify-content-center"> <div className="d-flex d-sm-none justify-content-center">
<span className="fw-semibold">Vídeos assistidos</span> <span className="fw-semibold">Vídeos assistidos</span>
<span className={`${styles.badge} badge align-content-center ms-3`}>{videosWatched}/{videosCount}</span> <span className={`${styles.badge} badge align-content-center ms-3`}><AnimatedCounter value={videosWatched} />/<AnimatedCounter value={videosCount} /></span>
</div> </div>
)} )}
@@ -183,7 +183,7 @@ export default function Header() {
<> <>
<div className="d-none d-sm-flex align-items-center gap-1 text-muted"> <div className="d-none d-sm-flex align-items-center gap-1 text-muted">
<span className="fw-semibold">Vídeos assistidos</span> <span className="fw-semibold">Vídeos assistidos</span>
<span className={`${styles.badge} badge align-content-center`}>{videosWatched}/{videosCount}</span> <span className={`${styles.badge} badge align-content-center`}><AnimatedCounter value={videosWatched} />/<AnimatedCounter value={videosCount} /></span>
</div> </div>
</> </>
)} )}
@@ -225,7 +225,7 @@ export default function Header() {
<div className="row"> <div className="row">
{videosSearched.length > 0 && ( {videosSearched.length > 0 && (
<span className="text-muted text-start d-block">Vídeos encontrados: {videosSearched.length}</span> <span className="text-muted text-start d-block">Vídeos encontrados: <AnimatedCounter value={videosSearched.length} /></span>
)} )}
{videosSearched.map((video) => ( {videosSearched.map((video) => (

View File

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

View File

@@ -8,13 +8,52 @@ import { LuMail } from "react-icons/lu";
import { LuLayoutDashboard } from "react-icons/lu"; import { LuLayoutDashboard } from "react-icons/lu";
import { LuLogOut } from "react-icons/lu"; import { LuLogOut } from "react-icons/lu";
import { useGetCurrentUser } from "../../hooks/useGetCurrentUser"; import { useGetCurrentUser } from "../../hooks/useGetCurrentUser";
import { motion, AnimatePresence } from "framer-motion";
// ✅ Fora do Sidebar — evita recriar a cada render
const MenuLabel = ({ show, children }: { show: boolean; children: React.ReactNode }) => (
<AnimatePresence>
{show && (
<motion.span
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: "auto" }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
style={{ overflow: "hidden", whiteSpace: "nowrap", display: "inline-block" }}
>
{children}
</motion.span>
)}
</AnimatePresence>
);
export default function Sidebar() { export default function Sidebar() {
const { getCurrentUser } = useGetCurrentUser(); const { getCurrentUser } = useGetCurrentUser();
const [role, setRole] = useState(0); const [role, setRole] = useState(0);
const [sideMenu, setSideMenu] = useState(true); const [sideMenu, setSideMenu] = useState(true);
const containerVariants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.1,
staggerDirection: 1
}
},
exit: {
transition: {
staggerChildren: 0.1,
staggerDirection: -1
}
}
};
const itemVariants = {
initial: { opacity: 0, x: -10 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -10 }
};
useEffect(() => { useEffect(() => {
const fetchCurrentUser = async () => { const fetchCurrentUser = async () => {
const currentUser = await getCurrentUser(); const currentUser = await getCurrentUser();
@@ -23,74 +62,107 @@ export default function Sidebar() {
fetchCurrentUser(); fetchCurrentUser();
}, []); }, []);
return sideMenu ? ( return (
<aside className={styles.sidebar}> // ✅ motion.aside anima a largura suavemente
<div className="d-flex"> <motion.aside
<Link className={`${styles.logoLink} justify-content-center mx-auto`} to="/dashboard"> className={styles.sidebar}
<img className={styles.logo} src="/src/assets/logo.png" alt="Logo" /> animate={{ width: sideMenu ? 260 : 100 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div className="d-flex justify-content-center" style={{ minHeight: '100px' }}>
<AnimatePresence mode="wait">
{sideMenu ? (
<motion.div
key="logo"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<Link className={`${styles.logoLink} justify-content-center`} to="/dashboard">
<img className={`${styles.logo}`} src="/src/assets/logo.png" alt="Logo" />
</Link> </Link>
</motion.div>
<button type="button" className={`${styles.sidebarClose} align-items-center justify-content-center`} onClick={() => setSideMenu(false)} style={{ position: 'absolute', left: '280px', top: '8px' }}> <LuPanelLeftClose size={30} /> </button>
</div>
<nav className={`${styles.nav}`}>
<ul className={styles.navList}>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard"> <LuLayoutDashboard className="me-2" size={24} /> {sideMenu ? "Dashboard" : ""} </NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/videos"> <LuTvMinimalPlay className="me-2" size={24} />Videos</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/workshops"> <LuGraduationCap className="me-2" size={24} /> {sideMenu ? "Workshops" : ""} </NavLink>
</li>
{role === 1 ? (
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/admin/users"> <LuUsers className="me-2" size={24} /> {sideMenu ? "Utilizadores" : ""} </NavLink>
</li>
) : null}
<li className={`${styles.navItem} text-start`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/contactos"> <LuMail className="me-2" size={24} /> {sideMenu ? "Contactos" : ""} </NavLink>
</li>
</ul>
</nav>
<div className={styles.logoutWrapper}>
<Link className={styles.logoutButton} to="/logout"> <LuLogOut className="me-2" size={24} />Logout</Link>
</div>
</aside>
) : ( ) : (
<div className={`${styles.sidebarIconsMenu}`}> <motion.div
<aside > key="open-btn"
<div className="d-flex px-2 mb-4 mt-2"> initial={{ opacity: 0 }}
<button type="button" className={`${styles.sidebarOpen} align-items-center bg-transparent border-0 justify-content-center`} onClick={() => setSideMenu(true)}> <LuPanelLeftOpen size={30} /> </button> animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="d-flex px-2 mb-4 mt-2"
>
<button
type="button"
className={`${styles.sidebarOpen} align-self-start bg-transparent border-0 justify-content-center`}
onClick={() => setSideMenu(true)}
>
<LuPanelLeftOpen size={30} />
</button>
</motion.div>
)}
</AnimatePresence>
{sideMenu ? (
<AnimatePresence>
<motion.button
key="close-btn"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: 0.3 }}
className={`${styles.sidebarClose} align-items-center justify-content-center`} onClick={() => setSideMenu(prev => !prev)} style={{ position: 'absolute', left: '280px', top: '8px' }}> {sideMenu ? <LuPanelLeftClose size={30} /> : <LuPanelLeftOpen size={30} />} </motion.button>
</AnimatePresence>
) : null}
</div> </div>
<nav className={`${styles.nav} px-2`}> <nav className={styles.nav}>
<ul className={styles.navList}> <motion.ul className={styles.navList}
<li className={`${styles.navItem}`} title="Dashboard"> variants={containerVariants}
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard"> <LuLayoutDashboard size={24} /></NavLink> initial="initial"
</li> animate="animate"
<li className={`${styles.navItem}`} title="Vídeos"> exit="exit">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/videos"> <LuTvMinimalPlay size={24} /></NavLink> <motion.li variants={itemVariants} className={`${styles.navItem} px-2 ${sideMenu ? "text-start" : "text-center"}`}>
</li> <NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard">
<li className={`${styles.navItem}`} title="Workshops"> <LuLayoutDashboard className={sideMenu ? "me-2" : "w-100"} size={24} />
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} justify-content-center ${styles.navLinkActive}` : styles.navLink} to="/workshops"> <LuGraduationCap size={24} /></NavLink> <MenuLabel show={sideMenu}>Dashboard</MenuLabel>
</li> </NavLink>
<li className={`${styles.navItem}`} title="Utilizadores"> </motion.li>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive} justify-content-center` : styles.navLink} to="/admin/users"> <LuUsers size={24} /></NavLink> <motion.li variants={itemVariants} className={`${styles.navItem} px-2 ${sideMenu ? "text-start" : "text-center"}`}>
</li> <NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/videos">
<li className={`${styles.navItem}`} title="Contactos"> <LuTvMinimalPlay className={sideMenu ? "me-2" : "w-100"} size={24} />
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/contactos"> <LuMail size={24} /></NavLink> <MenuLabel show={sideMenu}>Videos</MenuLabel>
</li> </NavLink>
</ul> </motion.li>
<motion.li variants={itemVariants} className={`${styles.navItem} px-2 ${sideMenu ? "text-start" : "text-center"}`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/workshops">
<LuGraduationCap className={sideMenu ? "me-2" : "w-100"} size={24} />
<MenuLabel show={sideMenu}>Workshops</MenuLabel>
</NavLink>
</motion.li>
{role === 1 && (
<motion.li variants={itemVariants} className={`${styles.navItem} px-2 ${sideMenu ? "text-start" : "text-center"}`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/admin/users">
<LuUsers className={sideMenu ? "me-2" : "w-100"} size={24} />
<MenuLabel show={sideMenu}>Utilizadores</MenuLabel>
</NavLink>
</motion.li>
)}
<motion.li variants={itemVariants} className={`${styles.navItem} px-2 ${sideMenu ? "text-start" : "text-center"}`}>
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/contactos">
<LuMail className={sideMenu ? "me-2" : "w-100"} size={24} />
<MenuLabel show={sideMenu}>Contactos</MenuLabel>
</NavLink>
</motion.li>
</motion.ul>
</nav> </nav>
<div className="d-flex align-self-end mx-2 position-absolute bottom-0 mb-2"> <div className={`${styles.logoutWrapper} px-2`}>
<Link className={styles.logoutButton} to="/logout" title="Logout"> <LuLogOut className="" size={24} /></Link> <Link className={styles.logoutButton} to="/logout">
<LuLogOut className={sideMenu ? "me-2" : "w-100"} size={24} />
<MenuLabel show={sideMenu}>Logout</MenuLabel>
</Link>
</div> </div>
</aside> </motion.aside>
</div>
); );
} }

View File

@@ -1,5 +1,4 @@
.sidebar { .sidebar {
width: 260px;
height: 100vh; height: 100vh;
background: #ffffff; background: #ffffff;
border-right: 1px solid #e9ecef; border-right: 1px solid #e9ecef;
@@ -65,6 +64,9 @@
color: #495057; color: #495057;
border-radius: 8px; border-radius: 8px;
padding: 10px 12px; padding: 10px 12px;
overflow: hidden;
display: flex;
align-items: center;
transition: background-color 0.2s ease, color 0.2s ease; transition: background-color 0.2s ease, color 0.2s ease;
&:hover{ &:hover{
background-color: var(--bg-grey); background-color: var(--bg-grey);

View File

@@ -5,7 +5,8 @@ import styles from "./styles.module.css";
import { LuArrowUpRight, LuCalendar, LuClock3, LuPencil } from "react-icons/lu"; import { LuArrowUpRight, LuCalendar, LuClock3, LuPencil } from "react-icons/lu";
import { Link } from "react-router"; import { Link } from "react-router";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import { ProgressBar } from "react-bootstrap"; import AnimatedProgressBar from "../../../components/animatedProgressBar";
import AnimatedCounter from "../../../components/animatedNumberCount";
/* import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser" /* import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser"
import { useGetVideosLength } from "../../../hooks/useGetVideosLength"; import { useGetVideosLength } from "../../../hooks/useGetVideosLength";
@@ -197,7 +198,7 @@ export default function Home() {
{role !== 1 && ( {role !== 1 && (
<> <>
<ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} /> <AnimatedProgressBar value={progressoVideos} className={`${styles.progressBar}`} />
{progressoVideos === 100 && ( {progressoVideos === 100 && (
<div className="text-center mt-3"> <div className="text-center mt-3">
@@ -348,12 +349,12 @@ export default function Home() {
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4 mx-sm-2 ms-lg-2 me-lg-0`}> <div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4 mx-sm-2 ms-lg-2 me-lg-0`}>
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-normal fs-3 text-white" > {role === 1 ? "Vídeos ativos" : "Vídeos assistidos"}</span> <span className="fw-normal fs-3 text-white" > {role === 1 ? "Vídeos ativos" : "Vídeos assistidos"}</span>
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{role === 1 ? videosCount : `${videosWatched}/${videosCount}`}</span> <span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{role === 1 ? <AnimatedCounter value={videosCount} /> : <AnimatedCounter value={videosWatched} />}/<AnimatedCounter value={videosCount} /></span>
</div> </div>
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>{role === 1 ? "Workshops agendados" : "Workshops inscrito"}</span> <span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>{role === 1 ? "Workshops agendados" : "Workshops inscrito"}</span>
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{role === 1 ? workshopsCount : `${workshopsInscribed}/${workshopsCount}`}</span> <span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{role === 1 ? <AnimatedCounter value={workshopsCount} /> : <AnimatedCounter value={workshopsInscribed} />}/{<AnimatedCounter value={workshopsCount} />}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import styles from "./styles.module.css";
import { LuCheck, LuKeyRound, LuPencil, LuX, LuArrowUpRight, LuClock3, LuCalendar } from "react-icons/lu"; import { LuCheck, LuKeyRound, LuPencil, LuX, LuArrowUpRight, LuClock3, LuCalendar } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg"; import { CgSpinner } from "react-icons/cg";
import { Link } from "react-router"; import { Link } from "react-router";
import { ProgressBar } from "react-bootstrap"; import AnimatedProgressBar from "../../../components/animatedProgressBar";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton"; import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { PiCheckCircleFill } from "react-icons/pi"; import { PiCheckCircleFill } from "react-icons/pi";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
@@ -311,7 +311,7 @@ export default function Profile() {
{role !== 1 && ( {role !== 1 && (
<> <>
<ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} /> <AnimatedProgressBar value={progressoVideos} className={`${styles.progressBar}`} />
{progressoVideos === 100 && ( {progressoVideos === 100 && (
<div className="text-center mt-3"> <div className="text-center mt-3">

View File

@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import type { ApiErrorResponse, Video } from "../../../types"; import type { ApiErrorResponse, Video } from "../../../types";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { LuArrowLeft } from "react-icons/lu"; import { LuArrowLeft, LuChevronLeft, LuChevronRight } from "react-icons/lu";
import { Plyr } from "plyr-react"; import { Plyr } from "plyr-react";
import "plyr-react/plyr.css"; import "plyr-react/plyr.css";
import { useVideoWatch } from "../../../hooks/useVideoWatch"; import { useVideoWatch } from "../../../hooks/useVideoWatch";
@@ -16,7 +16,8 @@ export default function Video() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [playerReady, setPlayerReady] = useState(false); const [playerReady, setPlayerReady] = useState(false);
const { watched, markAsWatched } = useVideoWatch(Number(id), video?.watched ?? false); const { watched, markAsWatched } = useVideoWatch(Number(id), video?.watched ?? false);
const [nextVideo, setNextVideo] = useState<number | null>(null);
const [previousVideo, setPreviousVideo] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
getVideo(); getVideo();
}, [id]); }, [id]);
@@ -79,6 +80,9 @@ export default function Video() {
async function getVideo() { async function getVideo() {
setLoading(true); setLoading(true);
setPlayerReady(false); setPlayerReady(false);
setNextVideo(null);
setPreviousVideo(null);
setVideo(null);
try { try {
const response = await fetch(`${API_URL}/api/video/${id}`, { const response = await fetch(`${API_URL}/api/video/${id}`, {
method: "GET", method: "GET",
@@ -93,6 +97,8 @@ export default function Video() {
if (response.ok) { if (response.ok) {
setVideo(data.data); setVideo(data.data);
setNextVideo(data.nextVideo as number);
setPreviousVideo(data.previousVideo as number);
setError(null); setError(null);
} else { } else {
setVideo(null); setVideo(null);
@@ -157,6 +163,15 @@ export default function Video() {
/> />
</div> </div>
</div> </div>
<div className="d-flex justify-content-between mt-3">
{previousVideo && (
<Link to={`/video/${previousVideo}`} className={`${styles.previousButton} fs-6 text-start`}><b><LuChevronLeft size={25} title="Vídeo anterior" /> Anterior</b></Link>
)}
{nextVideo && (
<Link to={`/video/${nextVideo}`} className={`${styles.nextButton} fs-6 text-end`}><b>Próximo <LuChevronRight size={25} title="Próximo vídeo" /> </b></Link>
)}
</div>
<p className="text-start mt-3 fs-6">{video.description}</p> <p className="text-start mt-3 fs-6">{video.description}</p>
</div> </div>
</div> </div>

View File

@@ -20,6 +20,23 @@
color: var(--primary-color); color: var(--primary-color);
} }
.previousButton,
.nextButton {
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
text-decoration: none;
padding: 10px 20px;
background-color: var(--bg-white);
color: var(--text-primary-color);
transition: all .3s ease;
&:hover {
background-color: var(--bg-primary-color-opacity);
}
}
.animateSpin { .animateSpin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }

View File

@@ -77,6 +77,8 @@ export type Video = {
watched: boolean; watched: boolean;
users: User[]; users: User[];
role: number; role: number;
nextVideo: number | null;
previousVideo: number | null;
} }
export type NextVideosResponse = { export type NextVideosResponse = {