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 { createChat } from '@n8n/chat';
import './n8n-chat-theme.css';
import { motion } from 'framer-motion';
export default function N8nChat() {
useEffect(() => {
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);
const app = createChat({
@@ -27,13 +42,56 @@ export default function N8nChat() {
return () => {
app.unmount();
document.head.removeChild(style);
};
}, []);
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
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-color: var(--shadow-primary);
--chat--input--send--button--color: var(--primary-color);
--chat--toggle--background: var(--primary-color);
--chat--toggle--border: 2px solid var(--primary-color);
--chat--toggle--background: transparent;
}
.chat-window-wrapper .chat-window-toggle {
@@ -29,8 +28,7 @@
}
.chat-window-wrapper .chat-window-toggle:hover {
background-color: var(--bg-grey) !important;
color: var(--text-primary-color) !important;
background-color: transparent !important;
}
.chat-layout .chat-header h1 {

View File

@@ -15,7 +15,7 @@ import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton";
import { motion, AnimatePresence } from "framer-motion";
import { useGetCurrentUser } from "../../hooks/useGetCurrentUser";
import AnimatedCounter from "../animatedNumberCount";
export default function Header() {
const [showMenu, setShowMenu] = useState(false);
@@ -166,7 +166,7 @@ export default function Header() {
{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`}>{videosWatched}/{videosCount}</span>
<span className={`${styles.badge} badge align-content-center ms-3`}><AnimatedCounter value={videosWatched} />/<AnimatedCounter value={videosCount} /></span>
</div>
)}
@@ -183,7 +183,7 @@ export default function Header() {
<>
<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`}>{videosWatched}/{videosCount}</span>
<span className={`${styles.badge} badge align-content-center`}><AnimatedCounter value={videosWatched} />/<AnimatedCounter value={videosCount} /></span>
</div>
</>
)}
@@ -225,7 +225,7 @@ export default function Header() {
<div className="row">
{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) => (

View File

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

View File

@@ -8,13 +8,52 @@ import { LuMail } from "react-icons/lu";
import { LuLayoutDashboard } from "react-icons/lu";
import { LuLogOut } from "react-icons/lu";
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() {
const { getCurrentUser } = useGetCurrentUser();
const [role, setRole] = useState(0);
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(() => {
const fetchCurrentUser = async () => {
const currentUser = await getCurrentUser();
@@ -23,74 +62,107 @@ export default function Sidebar() {
fetchCurrentUser();
}, []);
return sideMenu ? (
<aside className={styles.sidebar}>
<div className="d-flex">
<Link className={`${styles.logoLink} justify-content-center mx-auto`} to="/dashboard">
<img className={styles.logo} src="/src/assets/logo.png" alt="Logo" />
return (
// ✅ motion.aside anima a largura suavemente
<motion.aside
className={styles.sidebar}
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>
<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>
</motion.div>
) : (
<div className={`${styles.sidebarIconsMenu}`}>
<aside >
<div className="d-flex px-2 mb-4 mt-2">
<button type="button" className={`${styles.sidebarOpen} align-items-center bg-transparent border-0 justify-content-center`} onClick={() => setSideMenu(true)}> <LuPanelLeftOpen size={30} /> </button>
<motion.div
key="open-btn"
initial={{ opacity: 0 }}
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>
<nav className={`${styles.nav} px-2`}>
<ul className={styles.navList}>
<li className={`${styles.navItem}`} title="Dashboard">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/dashboard"> <LuLayoutDashboard size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Vídeos">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/videos"> <LuTvMinimalPlay size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Workshops">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} justify-content-center ${styles.navLinkActive}` : styles.navLink} to="/workshops"> <LuGraduationCap size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Utilizadores">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive} justify-content-center` : styles.navLink} to="/admin/users"> <LuUsers size={24} /></NavLink>
</li>
<li className={`${styles.navItem}`} title="Contactos">
<NavLink className={({ isActive }) => isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink} to="/contactos"> <LuMail size={24} /></NavLink>
</li>
</ul>
<nav className={styles.nav}>
<motion.ul className={styles.navList}
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit">
<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="/dashboard">
<LuLayoutDashboard className={sideMenu ? "me-2" : "w-100"} size={24} />
<MenuLabel show={sideMenu}>Dashboard</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="/videos">
<LuTvMinimalPlay className={sideMenu ? "me-2" : "w-100"} size={24} />
<MenuLabel show={sideMenu}>Videos</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="/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>
<div className="d-flex align-self-end mx-2 position-absolute bottom-0 mb-2">
<Link className={styles.logoutButton} to="/logout" title="Logout"> <LuLogOut className="" size={24} /></Link>
<div className={`${styles.logoutWrapper} px-2`}>
<Link className={styles.logoutButton} to="/logout">
<LuLogOut className={sideMenu ? "me-2" : "w-100"} size={24} />
<MenuLabel show={sideMenu}>Logout</MenuLabel>
</Link>
</div>
</aside>
</div>
</motion.aside>
);
}

View File

@@ -1,5 +1,4 @@
.sidebar {
width: 260px;
height: 100vh;
background: #ffffff;
border-right: 1px solid #e9ecef;
@@ -65,6 +64,9 @@
color: #495057;
border-radius: 8px;
padding: 10px 12px;
overflow: hidden;
display: flex;
align-items: center;
transition: background-color 0.2s ease, color 0.2s ease;
&:hover{
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 { Link } from "react-router";
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 { useGetVideosLength } from "../../../hooks/useGetVideosLength";
@@ -197,7 +198,7 @@ export default function Home() {
{role !== 1 && (
<>
<ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} />
<AnimatedProgressBar value={progressoVideos} className={`${styles.progressBar}`} />
{progressoVideos === 100 && (
<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="d-flex flex-column">
<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 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-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>

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

View File

@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import type { ApiErrorResponse, Video } from "../../../types";
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-react/plyr.css";
import { useVideoWatch } from "../../../hooks/useVideoWatch";
@@ -16,7 +16,8 @@ export default function Video() {
const [loading, setLoading] = useState(true);
const [playerReady, setPlayerReady] = useState(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(() => {
getVideo();
}, [id]);
@@ -79,6 +80,9 @@ export default function Video() {
async function getVideo() {
setLoading(true);
setPlayerReady(false);
setNextVideo(null);
setPreviousVideo(null);
setVideo(null);
try {
const response = await fetch(`${API_URL}/api/video/${id}`, {
method: "GET",
@@ -93,6 +97,8 @@ export default function Video() {
if (response.ok) {
setVideo(data.data);
setNextVideo(data.nextVideo as number);
setPreviousVideo(data.previousVideo as number);
setError(null);
} else {
setVideo(null);
@@ -157,6 +163,15 @@ export default function Video() {
/>
</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>
</div>
</div>

View File

@@ -20,6 +20,23 @@
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 {
animation: spin 1s linear infinite;
}

View File

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