feat: Adding animations

This commit is contained in:
Xavier Oliveira
2026-06-01 16:52:28 +01:00
parent 985f5ffb69
commit bcaff422a0
19 changed files with 528 additions and 233 deletions

View File

@@ -1,14 +1,26 @@
import { useMotionValue, useSpring } from "framer-motion";
import { useEffect, useState } from "react";
import { useInView, useMotionValue, useSpring } from "framer-motion";
import { useEffect, useRef, useState } from "react";
const AnimatedCounter = ({ value }: { value: number }) => {
type AnimatedCounterProps = {
value: number;
/** Só anima na primeira vez que entra no ecrã (default: true) */
once?: boolean;
};
const AnimatedCounter = ({ value, once = true }: AnimatedCounterProps) => {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once, amount: 0.8 });
const motionValue = useMotionValue(0);
const spring = useSpring(motionValue, { stiffness: 60, damping: 20 });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (isInView) {
motionValue.set(value);
}, [value]);
} else if (!once) {
motionValue.set(0);
}
}, [isInView, value, motionValue, once]);
useEffect(() => {
const unsubscribe = spring.on("change", (v) => {
@@ -17,7 +29,7 @@ const AnimatedCounter = ({ value }: { value: number }) => {
return unsubscribe;
}, [spring]);
return <span>{display}</span>;
return <span ref={ref}>{display}</span>;
};
export default AnimatedCounter;

View File

@@ -1,18 +1,29 @@
import { useMotionValue, useSpring } from "framer-motion";
import { useEffect, useState } from "react";
import { useInView, useMotionValue, useSpring } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { ProgressBar } from "react-bootstrap";
const AnimatedProgressBar = ({ value, className }: { value: number; className?: string }) => {
type AnimatedProgressBarProps = {
value: number;
className?: string;
once?: boolean;
};
const AnimatedProgressBar = ({ value, className, once = true }: AnimatedProgressBarProps) => {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once, amount: 0.5 });
const motionValue = useMotionValue(0);
const spring = useSpring(motionValue, { stiffness: 60, damping: 20 });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (isInView) {
motionValue.set(value);
}, [value]);
} else if (!once) {
motionValue.set(0);
}
}, [isInView, value, motionValue, once]);
useEffect(() => {
// ✅ subscribe mantém o state sincronizado com o spring
const unsubscribe = spring.on("change", (v) => {
setDisplay(Math.round(v));
});
@@ -20,11 +31,13 @@ const AnimatedProgressBar = ({ value, className }: { value: number; className?:
}, [spring]);
return (
<div ref={ref}>
<ProgressBar
className={className}
now={display}
label={`${display}%`}
/>
</div>
);
};

View File

@@ -14,6 +14,7 @@ import { PiCheckCircleFill } from "react-icons/pi";
import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton";
import { motion, AnimatePresence } from "framer-motion";
import { dropdownTransition, dropdownVariants, slideFromTopTransition, slideFromTopVariants } from "../../utils/pageMotionVariants";
import { useGetCurrentUser } from "../../hooks/useGetCurrentUser";
import AnimatedCounter from "../animatedNumberCount";
@@ -220,7 +221,7 @@ export default function Header() {
<>
{videosSearched.length > 0 ? (
<div className="d-flex flex-column">
<motion.div variants={slideFromTopVariants} initial="initial" animate="animate" transition={slideFromTopTransition} className="d-flex flex-column">
<span className="fs-1 text-center fw-bold mb-1 mt-3">Vídeos</span>
<div className="row">
@@ -249,12 +250,12 @@ export default function Header() {
))}
</div>
</div>
</motion.div>
) : null}
{workshopsSearched.length > 0 ? (
<div className="d-flex flex-column">
<motion.div variants={slideFromTopVariants} initial="initial" animate="animate" transition={slideFromTopTransition} className="d-flex flex-column">
<span className="fs-1 text-center fw-bold mb-1 mt-3">Workshops</span>
<div className="row g-3 mb-3">
{workshopsSearched.length > 0 && (
@@ -295,7 +296,7 @@ export default function Header() {
))}
</div>
</div>
</motion.div>
) : null}
</>
@@ -319,10 +320,11 @@ export default function Header() {
<AnimatePresence>
{showDropdown && (
<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" }}
variants={dropdownVariants}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
className="dropdown-menu dropdown-menu-end"
style={{ display: "block", zIndex: 2000, right: 0, top: "100%", marginTop: "0.25rem" }}>
<li><Link className="dropdown-item" to="/profile"><LuCircleUser className="mb-1 me-2" size={24} />A minha conta</Link></li>

View File

@@ -6,6 +6,8 @@ import { LuArrowLeft } from "react-icons/lu";
import { LuPlus } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2";
import { motion } from "framer-motion";
import { slideFromLeftVariants, slideFromLeftOnMountTransition, pageTitleTransition, pageTitleVariants, slideFromBottomVariants, slideFromBottomTransition } from "../../../../utils/pageMotionVariants";
export default function CreateUser() {
const [name, setName] = useState<string>("");
@@ -15,6 +17,7 @@ export default function CreateUser() {
const [role_id, setRoleId] = useState("2");
const [creating, setCreating] = useState(false);
async function create() {
//validação dos campos
@@ -113,13 +116,13 @@ export default function CreateUser() {
return (
<div className={`${styles.container} p-3 p-xl-0`}>
<div className={"text-start"}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} className={"text-start"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/admin/users"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Utilizadores</span> </Link></button>
</div>
<h1 className={styles.title}>Criar Utilizador</h1>
</motion.div>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Criar Utilizador</motion.h1>
<div>
<div>
<motion.div variants={slideFromBottomVariants} initial="initial" animate="animate" transition={slideFromBottomTransition} >
<div className="row g-3">
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="name">Nome</label>
@@ -149,7 +152,7 @@ export default function CreateUser() {
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="submit" className={`${styles.createButton}`} onClick={() => { create(); setCreating(true); }} disabled={creating}>{creating ? <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> : <LuPlus className="mb-1 text-white" />} Adicionar</button>
</div>
</div>
</motion.div>
</div>
</div>
)

View File

@@ -7,6 +7,15 @@ import { LuArrowLeft, LuPlus } from "react-icons/lu";
import { LuUpload } from "react-icons/lu";
import Swal from "sweetalert2";
import { CgSpinner } from "react-icons/cg";
import { motion } from "framer-motion";
import {
pageTitleTransition,
pageTitleVariants,
slideFromBottomTransition,
slideFromBottomVariants,
slideFromLeftOnMountTransition,
slideFromLeftVariants,
} from "../../../../utils/pageMotionVariants";
export default function CreateVideo() {
const [title, setTitle] = useState<string>("");
@@ -208,10 +217,12 @@ export default function CreateVideo() {
return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} className="text-start">
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/videos"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Vídeos</span> </Link></button>
</div>
<h1 className={styles.title}>Adicionar Vídeo</h1>
</motion.div>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Adicionar Vídeo</motion.h1>
<div>
<motion.div variants={slideFromBottomVariants} initial="initial" animate="animate" transition={slideFromBottomTransition}>
<div className="row mx-auto g-4">
<div className="col-12 px-1 text-start">
<label className="form-label fw-bold" htmlFor="title">Título</label>
@@ -302,6 +313,8 @@ export default function CreateVideo() {
)}
<button type="button" className={`${styles.btnAdicionarVideo} btn btn-primary mt-5`} onClick={() => { createVideo() }} disabled={creating}>{creating ? <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> : <LuPlus />} Adicionar vídeo</button>
</motion.div>
</div>
</div>
)
}

View File

@@ -8,6 +8,15 @@ import "react-datepicker/dist/react-datepicker.css";
import { pt } from "date-fns/locale";
import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2";
import { motion } from "framer-motion";
import {
pageTitleTransition,
pageTitleVariants,
slideFromBottomTransition,
slideFromBottomVariants,
slideFromLeftOnMountTransition,
slideFromLeftVariants,
} from "../../../../utils/pageMotionVariants";
export default function CreateWorkshop() {
const [title, setTitle] = useState<string>("");
@@ -92,10 +101,12 @@ export default function CreateWorkshop() {
return (
<div className={`${styles.container} p-3 p-xl-0 px-2 px-md-5`}>
<div className={"text-start"}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} className="text-start">
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/workshops"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Workshops</span> </Link></button>
</div>
<h1 className={styles.title}>Adicionar Workshop</h1>
</motion.div>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Adicionar Workshop</motion.h1>
<div>
<motion.div variants={slideFromBottomVariants} initial="initial" animate="animate" transition={slideFromBottomTransition}>
<div className="row mx-auto g-4">
<div className="col-12 px-1 text-start">
<label className={`${styles.label} form-label fw-bold`} htmlFor="title">Título</label>
@@ -171,6 +182,8 @@ export default function CreateWorkshop() {
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<button type="submit" onClick={createWorkshop} className={`${styles.btnAdicionarWorkshop}`} disabled={isLoading}><LuPlus className="mb-1 text-white" /> {creating ? <CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} /> : "Adicionar Workshop"}</button>
</div>
</motion.div>
</div>
</div>
)
}

View File

@@ -6,6 +6,8 @@ import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
import { LuUpload, LuPlus, LuCheck, LuTrash2, LuArrowLeft } from "react-icons/lu";
import Swal from "sweetalert2";
import { slideFromLeftVariants, slideFromLeftOnMountTransition, pageTitleTransition, pageTitleVariants, slideFromBottomVariants, slideFromBottomTransition } from "../../../../utils/pageMotionVariants";
import { motion } from "framer-motion";
export default function editVideo() {
const { id } = useParams();
@@ -247,17 +249,15 @@ export default function editVideo() {
</div>
) : (
<div className={`${styles.container} p-3 p-xl-0`}>
<div className={"text-start"}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} className={"text-start"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/videos"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Vídeos</span> </Link></button>
</div>
</motion.div>
{video ? (
<div className="my-3">
<div className="mt-5">
<span className={styles.title}>Alterar dados do vídeo</span>
<div className="row mx-auto g-4">
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Alterar dados do vídeo</motion.h1>
<motion.div variants={slideFromBottomVariants} initial="initial" animate="animate" transition={slideFromBottomTransition} className="row mx-auto g-4">
<form method="patch" onSubmit={update}>
<input type="hidden" name="video_id" value={video?.id} />
<div className="col-12 px-1 text-start mb-3">
@@ -337,7 +337,7 @@ export default function editVideo() {
<button type="button" className={`${styles.deleteButton}`} onClick={() => handleDeleteVideo()}>Apagar <LuTrash2 className="mb-1 text-white" /></button>
</div>
</form>
</div>
</motion.div>
</div>

View File

@@ -10,6 +10,8 @@ import "react-datepicker/dist/react-datepicker.css";
import Swal from "sweetalert2";
import styles from "./styles.module.css";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
import { motion } from "framer-motion";
import { slideFromLeftVariants, slideFromLeftOnMountTransition, slideFromLeftTransition, slideFromRightVariants, slideFromRightTransition, slideFromTopTransition, slideFromTopVariants } from "../../../../utils/pageMotionVariants";
export default function Workshop() {
const { id } = useParams();
@@ -192,19 +194,19 @@ export default function Workshop() {
return (
<div className={`${styles.container} p-3 p-xl-0`}>
<div className={"text-start mb-3"}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} className={"text-start mb-3"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}>
<Link className={`${styles.link} text-decoration-none`} to="/workshops">
<LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Workshops</span>
</Link>
</button>
</div>
</motion.div>
{workshop ? (
<>
<div className={`${styles.container} d-flex flex-column gap-2`}>
<div className="row mt-4 g-3 gx-md-4 gx-lg-5 ms-0">
<div className={`${styles.thumbnailWrapper} col-12 col-lg-3 align-self-center align-self-sm-center px-0 mt-0`}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftTransition} className={`${styles.thumbnailWrapper} col-12 col-lg-3 align-self-center align-self-sm-center px-0 mt-0`}>
<img
src={`${API_URL}/storage/${workshop.image}`}
alt={workshop.title}
@@ -212,8 +214,8 @@ export default function Workshop() {
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
</div>
<div className="col-12 col-lg-9 d-flex flex-column justify-content-between gap-2">
</motion.div>
<motion.div variants={slideFromRightVariants} initial="initial" animate="animate" transition={slideFromRightTransition} 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">
{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" }}>
@@ -253,12 +255,12 @@ export default function Workshop() {
) : null}
</div>
</div>
</div>
</motion.div>
</div>
{listagemInscritos ? (
<div className={`${styles.users} mt-4`}>
<motion.div variants={slideFromTopVariants} initial="initial" animate="animate" transition={slideFromTopTransition} className={`${styles.users} mt-4`}>
<div className="table-responsive">
<button type="button" className={`${styles.btnClose} d-flex float-end p-1 mb-1`} onClick={() => setListagemInscritos(false)}><LuX className={`${styles.iconClose}`} /> </button>
<table className="table table-striped table-hover align-middle mt-3">
@@ -292,12 +294,12 @@ export default function Workshop() {
</tbody>
</table>
</div>
</div>
</motion.div>
) : null}
</div>
{!formEdit && role === 1 ? (
<div className={`${styles.buttonsContainer} d-flex flex-wrap gap-2 justify-content-center mt-5`}>
<motion.div variants={slideFromTopVariants} initial="initial" animate="animate" transition={slideFromTopTransition} className={`${styles.buttonsContainer} d-flex flex-wrap gap-2 justify-content-center mt-5`}>
<button
type="button"
className={`${styles.updateButton} bg-primary`}
@@ -317,14 +319,14 @@ export default function Workshop() {
{workshop.status === "pending" ? (
<button type="button" className={`${styles.cancelButton}`} onClick={handleCancelWorkshop}><LuTrash2 className="mb-1 btn-danger" /> Cancelar Workshop</button>
) : null}
</div>
</motion.div>
) : null}
{formEdit ? (
<form className={`${styles.formEdit} mt-5`} onSubmit={update}>
<motion.form variants={slideFromTopVariants} initial="initial" animate="animate" transition={slideFromTopTransition} className={`${styles.formEdit} mt-5`} onSubmit={update}>
<span className={styles.subtitle}>Alterar detalhes do workshop</span>
<div className="row mx-auto g-4 mt-1">
<div className="col-12 px-1 text-start">
@@ -453,7 +455,7 @@ export default function Workshop() {
<button type="submit" className={`${styles.updateButton} bg-primary`} ><LuCheck className="mb-1" /> Submeter dados</button>
</div>
</div>
</form>
</motion.form>
) : null}
</>
) : (

View File

@@ -7,6 +7,8 @@ import { LuCheck, LuTrash2, LuX, LuPencil, LuArrowLeft, LuCalendar, LuClock3 } f
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
import { CgSpinner } from "react-icons/cg";
import Swal from "sweetalert2";
import { motion } from "framer-motion";
import { slideFromLeftVariants, slideFromLeftOnMountTransition, pageTitleTransition, pageTitleVariants, slideFromBottomVariants, slideFromBottomTransition, slideFromRightVariants, slideFromRightTransition, slideFromTopVariants, slideFromTopTransition, viewportOnce, cardInViewTransition, cardInViewVariants } from "../../../../utils/pageMotionVariants";
export default function User() {
const { id } = useParams();
@@ -161,17 +163,17 @@ export default function User() {
return (
<div className={`${styles.container} p-1 p-xl-0`}>
<div className={"text-start mt-5"}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} className={"text-start mt-5"}>
<button className={`${styles.button} border-0 bg-transparent fs-5`}><Link className={`${styles.link} text-decoration-none`} to="/admin/users"><LuArrowLeft className={`${styles.LinkIcon} mb-1 fs-4 align-items-center`} /> <span className={styles.linkText}>Utilizadores</span> </Link></button>
</div>
</motion.div>
{user ? (
<div className="my-3">
<span className={styles.title}>Dados do Utilizador </span>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Dados do Utilizador </motion.h1>
<div className="row mt-5 justify-content-between px-2">
<div className="col-12 col-lg-8 p-2 rounded-3">
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} 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">
<span className={`badge text-uppercase bg-primary fw-bold px-4 py-3 fs-6 mb-3 d-flex d-md-none align-self-center ${user.role_id === 1 ? 'bg-primary bg-gradient text-white' : 'bg-secondary bg-gradient text-white'} `} style={{ width: "fit-content" }}>{user.role_id === 1 ? "Administrador" : "Utilizador"}</span>
@@ -192,8 +194,8 @@ export default function User() {
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-4 p-2">
</motion.div>
<motion.div variants={slideFromRightVariants} initial="initial" animate="animate" transition={slideFromRightTransition} className="col-12 col-lg-4 p-2">
<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>
@@ -206,15 +208,49 @@ export default function User() {
</div>
</div>
</motion.div>
<motion.div className="mt-3">
{formEdit ? (
<motion.div variants={slideFromTopVariants} initial="initial" animate="animate" transition={slideFromTopTransition} className="my-3">
<span className={styles.title}>Editar Utilizador</span>
<form method="patch" onSubmit={update} id="formEditUser">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<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} />
</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} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="role_id">Cargo</label>
<select className="form-select py-2" id="role_id" name="role_id" defaultValue={user?.role_id}>
<option value="1">Administrador</option>
<option value="2">Utilizador</option>
</select>
</div>
</div>
<div className="col-12 p-2 mt-4 mb-3">
<span className={`${styles.subtitle} text-start`}>Workshops inscrito</span>
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<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>
</div>
</form>
</motion.div>
) : null}
</motion.div>
<motion.div variants={slideFromBottomVariants} initial="initial" animate="animate" transition={slideFromBottomTransition} className="col-12 p-2 mt-4 mb-3">
<span className={`${styles.subtitle} text-start`}>Workshops inscrito</span>
</motion.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`}>
<motion.div variants={cardInViewVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={cardInViewTransition} className={`${styles.boxWorkshop} text-start pb-3`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
src={`${API_URL}/storage/${workshop.image}`}
@@ -243,49 +279,17 @@ export default function User() {
Ver detalhes
</Link>
</div>
</div>
</motion.div>
</div>
))
) : (
<div className="col-12 text-center ps-1">
<motion.div variants={slideFromBottomVariants} whileInView="animate" viewport={viewportOnce} transition={slideFromBottomTransition} className="col-12 text-center ps-1">
<span className="text-muted fs-5">Este utilizador ainda não participou em nenhum workshop</span>
</div>
</motion.div>
)}
</div>
<div className="mt-5">
{formEdit ? (
<div className="my-3">
<span className={styles.title}>Editar Utilizador</span>
<form method="patch" onSubmit={update} id="formEditUser">
<div className="row g-3 mt-2">
<input type="hidden" name="user_id" value={user?.id} />
<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} />
</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} />
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label fw-bold" htmlFor="role_id">Cargo</label>
<select className="form-select py-2" id="role_id" name="role_id" defaultValue={user?.role_id}>
<option value="1">Administrador</option>
<option value="2">Utilizador</option>
</select>
</div>
</div>
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center" >
<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>
</div>
</form>
</div>
) : null}
</div>
</div>
) : (
<div className="text-center alert alert-danger mt-5 align-items-center">

View File

@@ -8,6 +8,22 @@ import { LuPencil, LuSettings2, LuPlus } from "react-icons/lu";
import { CgSpinner } from "react-icons/cg";
import { Form, Pagination, Table } from "react-bootstrap";
import { AnimatePresence, motion } from "framer-motion";
import {
dropdownTransition,
dropdownVariants,
fadeTransition,
fadeVariants,
listContentTransition,
listContentVariants,
pageTitleTransition,
pageTitleVariants,
slideFromLeftTransition,
slideFromLeftVariants,
slideFromRightOnMountTransition,
slideFromRightTransition,
slideFromRightVariants,
viewportOnce,
} from "../../../../utils/pageMotionVariants";
export default function Users() {
@@ -22,6 +38,7 @@ export default function Users() {
const [listTotal, setListTotal] = useState(0);
const [loadingUsers, setLoadingUsers] = useState(false);
const debouncedSearch = useDebounce(search, 500);
const listContentKey = `${currentPage}-${debouncedSearch}-${selectedUsers}`;
useEffect(() => {
index(debouncedSearch);
@@ -79,19 +96,18 @@ export default function Users() {
}
return (
<div className={`${styles.container} p-3 p-xl-0`}>
<h1 className={styles.title}>Utilizadores</h1>
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Utilizadores</motion.h1>
<div className="row py-3 g-4 justify-content-between">
<div className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2 ">
<motion.div variants={slideFromLeftVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromLeftTransition} className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2">
<Form.Control type="text" placeholder="Pesquise por nome ou email..."
value={search} onChange={(e) => {
setSearch(e.target.value);
}} />
</motion.div>
</div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
<motion.div variants={slideFromRightVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromRightTransition} className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
<div
className="btn-group flex-grow-1 position-relative"
onMouseEnter={() => setShowFilterDropdown(true)}
@@ -104,10 +120,11 @@ export default function Users() {
<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" }}
variants={dropdownVariants}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
className="dropdown-menu text-center w-100"
style={{ display: "block", zIndex: 4000, top: "100%", marginTop: "0.25rem" }}
>
@@ -142,18 +159,29 @@ export default function Users() {
)}
</AnimatePresence>
</div>
</div>
</motion.div>
<div className="col-12 col-sm col-md-12 col-lg-3 text-end align-content-center mt-4 mt-lg-4">
<motion.div variants={slideFromRightVariants} initial="initial" animate="animate" transition={slideFromRightOnMountTransition} 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-user" className={`${styles.createButton} text-decoration-none`}><LuPlus className="mb-1" /> Novo Utilizador</Link>
</div>
</motion.div>
</div>
<div className="mt-3">
<AnimatePresence mode="wait">
{loadingUsers ? (
<div className="text-center mt-5">
<motion.div key="loading" variants={fadeVariants} initial="initial" animate="animate" exit="exit" transition={fadeTransition} className="text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
) : users.length > 0 ? (
</motion.div>
) : (
<motion.div
key={listContentKey}
variants={listContentVariants}
initial="initial"
animate="animate"
exit="exit"
transition={listContentTransition}
>
{users.length > 0 ? (
<div>
<div className={`${styles.table} mt-3 mb-2`}>
<Table responsive className="mb-0">
@@ -229,6 +257,10 @@ export default function Users() {
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -6,6 +6,18 @@ import { useState } from "react";
import type { ApiErrorResponse } from "../../../types";
import { Navigate } from "react-router";
import AccordionFAQS from "../../../components/accordion FAQ´s/accordionFAQS";
import { motion } from "framer-motion";
import {
pageTitleTransition,
pageTitleVariants,
slideFromBottomTransition,
slideFromBottomVariants,
slideFromLeftTransition,
slideFromLeftVariants,
slideFromRightTransition,
slideFromRightVariants,
viewportOnce,
} from "../../../utils/pageMotionVariants";
export default function Contactos() {
const token = localStorage.getItem("token");
@@ -64,9 +76,9 @@ export default function Contactos() {
return (
<div className={styles.container} >
<h1 className={styles.title}>Contactos</h1>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Contactos</motion.h1>
<div className="row">
<div className="col-12 col-lg-6 text-start d-flex flex-column gap-3 px-4 mt-3 mt-lg-0">
<motion.div variants={slideFromLeftVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromLeftTransition} className="col-12 col-lg-6 text-start d-flex flex-column gap-3 px-4 mt-3 mt-lg-0">
<div className="text-center text-sm-start">
<h2 className={styles.subtitle}>Os nossos dados</h2>
<span >Pode contactar-nos através do nosso email, telefone ou morada.</span>
@@ -91,8 +103,8 @@ export default function Contactos() {
<span className={styles.contactData}>Rua da Escola, 123, Lisboa, Portugal</span></div>
</a>
</div>
</div>
<div className="col-12 col-lg-6 text-start d-flex flex-column gap-4 px-4 mt-5 mt-lg-0">
</motion.div>
<motion.div variants={slideFromRightVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromRightTransition} className="col-12 col-lg-6 text-start d-flex flex-column gap-4 px-4 mt-5 mt-lg-0">
<div className="text-center text-sm-start">
<h2 className={styles.subtitle}>Formulário de contacto</h2>
<span>Preencha o formulário abaixo para entrar em contacto connosco</span>
@@ -142,16 +154,16 @@ export default function Contactos() {
</div>
</form>
</div>
</div>
</motion.div>
<div className="col-12 mt-5 text-center">
<motion.div variants={slideFromBottomVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromBottomTransition} className="col-12 mt-5 text-center">
<span className={`${styles.title}`}>Perguntas Frequentes</span>
<div className="row mt-4">
<div className="col-12 mx-auto">
<AccordionFAQS />
</div>
</div>
</div>
</motion.div>
</div>
</div >
);

View File

@@ -7,6 +7,16 @@ import { Link } from "react-router";
import { CgSpinner } from "react-icons/cg";
import AnimatedProgressBar from "../../../components/animatedProgressBar";
import AnimatedCounter from "../../../components/animatedNumberCount";
import { motion } from "framer-motion";
import {
slideFromLeftOnMountTransition,
slideFromLeftInViewTransition03,
slideFromLeftVariants,
slideFromRightOnMountTransition02,
slideFromRightInViewTransition04,
slideFromRightVariants,
viewportOnce,
} from "../../../utils/pageMotionVariants";
/* import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser"
import { useGetVideosLength } from "../../../hooks/useGetVideosLength";
@@ -190,7 +200,7 @@ export default function Home() {
return (
<div className={`${styles.container} mx-2 mx-sm-0 p-2 p-sm-4 p-lg-0`}>
<div className={`${styles.containerVideos} px-2 p-sm-4 mx-sm-1 mx-lg-0`}>
<motion.div variants={slideFromLeftVariants} initial="initial" animate="animate" transition={slideFromLeftOnMountTransition} className={`${styles.containerVideos} px-2 p-sm-4 mx-sm-1 mx-lg-0`}>
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center px-0" >
<h2 className={`${styles.subtitle} text-center text-sm-start mt-4 mt-sm-3 mb-4 px-0 `}>{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>
@@ -198,7 +208,7 @@ export default function Home() {
{role !== 1 && (
<>
<AnimatedProgressBar value={progressoVideos} className={`${styles.progressBar}`} />
<AnimatedProgressBar value={progressoVideos ?? 0} className={`${styles.progressBar}`} />
{progressoVideos === 100 && (
<div className="text-center mt-3">
@@ -237,9 +247,9 @@ export default function Home() {
</div>
) : null}
</div>
</div>
</motion.div>
<div className="ms-0 px-lg-4 mt-4">
<motion.div variants={slideFromRightVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromRightOnMountTransition02} className="ms-0 px-lg-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`}>Próximos workshops</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>
@@ -290,11 +300,11 @@ export default function Home() {
</div>
) : null}
</div>
</div>
</motion.div>
<div className="row">
<div className="col-12 col-lg-8 mt-5 px-3">
<motion.div variants={slideFromLeftVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromLeftInViewTransition03} className="col-12 col-lg-8 mt-5 px-3">
<form onSubmit={sendMail} className="pt-2 px-1">
<div className="row g-3 bg-white p-4 mt-3" style={{ borderRadius: "var(--border-radius)" }}>
<div className="text-center text-sm-start mb-4">
@@ -342,23 +352,33 @@ export default function Home() {
</div>
</div>
</form>
</div>
</motion.div>
<div className="col-12 col-lg-4 mt-lg-5 px-sm-0 ps-lg-4 pe-lg-3 px-2">
<motion.div variants={slideFromRightVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromRightInViewTransition04} className="col-12 col-lg-4 mt-lg-5 px-sm-0 ps-lg-4 pe-lg-3 px-2">
<div className="h-100 pt-4 px-1 px-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">
<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 ? <AnimatedCounter value={videosCount} /> : <AnimatedCounter value={videosWatched} />}/<AnimatedCounter value={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 ? <AnimatedCounter value={workshopsCount} /> : <AnimatedCounter value={workshopsInscribed} />}/{<AnimatedCounter value={workshopsCount} />}</span>
</div>
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>
{role === 1
? <AnimatedCounter value={workshopsCount} />
: <> <AnimatedCounter value={workshopsInscribed} />/<AnimatedCounter value={workshopsCount} /></>
}
</span>
</div>
</div>
</div>
</motion.div>
</div>
</div>

View File

@@ -6,9 +6,27 @@ import { LuCheck, LuKeyRound, LuPencil, LuX, LuArrowUpRight, LuClock3, LuCalenda
import { CgSpinner } from "react-icons/cg";
import { Link } from "react-router";
import AnimatedProgressBar from "../../../components/animatedProgressBar";
import AnimatedCounter from "../../../components/animatedNumberCount";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { PiCheckCircleFill } from "react-icons/pi";
import Swal from "sweetalert2";
import { motion } from "framer-motion";
import {
cardInViewTransition,
cardInViewVariants,
pageTitleTransition,
pageTitleVariants,
slideFromBottomInViewTransition,
slideFromBottomVariants,
slideFromLeftTransitionNoDelay,
slideFromLeftVariants,
slideFromRightTransition,
slideFromRightVariants,
slideFromTopTransition,
slideFromTopTransitionDelayed,
slideFromTopVariants,
viewportOnce,
} from "../../../utils/pageMotionVariants";
export default function Profile() {
const [role, setRole] = useState(0);
@@ -197,11 +215,11 @@ export default function Profile() {
return (
<div className="container">
<span className={styles.title}>Os meus dados</span>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={styles.title}>Os meus dados</motion.h1>
<div>
<div className="my-3">
<div className="row mt-5 justify-content-between">
<div className="col-12 col-lg-8 p-3 rounded-3">
<motion.div variants={slideFromLeftVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromLeftTransitionNoDelay} className="col-12 col-lg-8 p-3 rounded-3">
<div className={`${styles.userCard} p-4 bg-white h-100`}>
<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">
@@ -213,46 +231,46 @@ export default function Profile() {
<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(userData?.created_at as string).toLocaleDateString('pt-PT')}</b></span>
<div className="d-flex flex-wrap 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); }}> 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); setFormEditPassword(false); }}> 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); }}> Alterar password <LuKeyRound className={`${styles.icon} mb-1 text-danger`} /></a>
`} onClick={() => { setFormEditPassword(true); setFormEdit(false); }}> 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">
</motion.div>
<motion.div variants={slideFromRightVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromRightTransition} className="col-12 col-lg-4 p-3">
{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-grey)" }}>Vídeos ativos</span>
<span className="fs-2 fw-bold text-white">{videosCount}</span>
<span className="fs-2 fw-bold text-white"><AnimatedCounter value={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">{workshopsCount}</span>
<span className="fs-2 fw-bold text-white"><AnimatedCounter value={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-grey)" }}>Vídeos assistidos</span>
<span className="fs-2 fw-bold text-white">{videosWatched}/{videosCount}</span>
<span className="fs-2 fw-bold text-white"><AnimatedCounter value={videosWatched} />/<AnimatedCounter value={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">{workshopsCount}</span>
<span className="fs-2 fw-bold text-white"><AnimatedCounter value={workshopsCount} /></span>
</div>
</div>
)}
</div>
</motion.div>
</div>
</div>
</div>
{formEdit ? (
<div className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<motion.div variants={slideFromTopVariants} initial="initial" whileInView="animate" transition={slideFromTopTransition} className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<span className={styles.title}>Editar dados</span>
<form method="patch" onSubmit={update} id="formEditUser">
@@ -273,9 +291,9 @@ export default function Profile() {
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
</div>
</form>
</div>
</motion.div>
) : formEditPassword ? (
<div className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<motion.div variants={slideFromTopVariants} initial="initial" whileInView="animate" transition={slideFromTopTransitionDelayed} className={`${styles.containerForm} my-3 px-2 p-sm-4`}>
<span className={styles.title}>Alterar password</span>
<form method="patch" onSubmit={updatePassword} id="formEditPassword">
@@ -300,10 +318,10 @@ export default function Profile() {
<button type="submit" className={`${styles.submitFormButton}`} >Submeter dados <LuCheck className="mb-1" /></button>
</div>
</form>
</div>
</motion.div>
) : null}
<div className={`${styles.containerVideos} mt-4 px-2 p-sm-4 mx-1`}>
<motion.div variants={slideFromBottomVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromBottomInViewTransition} 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>
@@ -311,7 +329,7 @@ export default function Profile() {
{role !== 1 && (
<>
<AnimatedProgressBar value={progressoVideos} className={`${styles.progressBar}`} />
<AnimatedProgressBar value={progressoVideos ?? 0} className={`${styles.progressBar}`} />
{progressoVideos === 100 && (
<div className="text-center mt-3">
@@ -323,8 +341,8 @@ export default function Profile() {
<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}>
<motion.div key={video.id} variants={cardInViewVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={cardInViewTransition} className="col-12 col-sm-6 col-lg-4 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`}>
<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>
@@ -342,23 +360,23 @@ export default function Profile() {
{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>
</motion.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>
</motion.div>
<div className="ms-0 px-lg-4 mt-4">
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center px-0" >
<motion.div variants={cardInViewVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={cardInViewTransition} 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>
</motion.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">
<motion.div key={workshop.id} variants={cardInViewVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={cardInViewTransition} className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} d-flex flex-column text-start pb-3 h-100`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
@@ -382,13 +400,13 @@ export default function Profile() {
<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}>
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => cancelarInscricao(workshop.id)}>
Anular inscrição
</button>
) : null}
</div>
</div>
</div>
</motion.div>
)
) : workshopsCount === 0 ? (
<div className="col-12 text-center px-0 mt-3">
@@ -397,7 +415,7 @@ export default function Profile() {
) : null}
{role === 1 && workshopsCount > 0 ? nextWorkshops.map((workshop: Workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<motion.div key={workshop.id} variants={cardInViewVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={cardInViewTransition} className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} d-flex flex-column text-start pb-3 h-100`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
@@ -421,7 +439,7 @@ export default function Profile() {
<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>
</motion.div>
)
) : workshopsCount === 0 ? (
<div className="col-12 text-center px-0 mt-3">

View File

@@ -169,6 +169,24 @@
color: var(--text-white);
}
.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: 10px;
right: 10px;
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover {
background-color: var(--neutral-color);
}
}
.titleVideo {
color: var(--text-white);
font-size: var(--size-font-text);
@@ -227,10 +245,6 @@
height: 150px;
}
.icon{
color: var(--text-primary-color);
}
.linkWorkshop {
display: block;
width: 160px;

View File

@@ -9,6 +9,9 @@ import { useGetVideosSearch } from "../../../hooks/useGetVideosSearch";
import { useGetWorkshopsSearch } from "../../../hooks/useGetWorkshopsSearch";
import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { motion } from "framer-motion";
import { pageTitleTransition, pageTitleVariants, slideFromBottomTransition, slideFromBottomVariants, slideFromTopTransition, slideFromTopVariants, viewportOnce } from "../../../utils/pageMotionVariants";
export default function Search() {
const [videos, setVideos] = useState<Video[]>([]);
@@ -76,14 +79,14 @@ export default function Search() {
return (
<div className={styles.container}>
<h1 className={`${styles.subtitle} mb-4`}>Resultados da pesquisa: "{query}"</h1>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={`${styles.subtitle} mb-4`}>Resultados da pesquisa: "{query}"</motion.h1>
{videos.length > 0 ? (
<div className="row g-3 p-0">
<h2 className={`${styles.title} text-center text-md-start`}>Vídeos</h2>
<span className="text-muted text-start mt-0">{videos.length === 1 ? `Foi encontrado ${videos.length} vídeo na sua pesquisa.` : `Foram encontrados ${videos.length} vídeos na sua pesquisa.`}</span>
<motion.h2 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={`${styles.title} text-start`}>Vídeos</motion.h2>
<motion.span variants={slideFromTopVariants} initial="initial" animate="animate" transition={slideFromTopTransition} className="text-muted text-start mt-0">{videos.length === 1 ? `Foi encontrado ${videos.length} vídeo na sua pesquisa.` : `Foram encontrados ${videos.length} vídeos na sua pesquisa.`}</motion.span>
{videos.map((video) => (
<div className="col-12 col-sm-6 col-lg-4 p-2">
<motion.div variants={slideFromBottomVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromBottomTransition} 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 && (
@@ -102,17 +105,17 @@ export default function Search() {
{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>
</motion.div>
))}
</div>
) : null}
{workshops.length > 0 ? (
<div className="row g-3 p-0 mt-3">
<h2 className={`${styles.title} text-center text-md-start`}>Workshops</h2>
<span className="text-muted text-start mt-0">{workshops.length === 1 ? `Foi encontrado ${workshops.length} workshop na sua pesquisa.` : `Foram encontrados ${workshops.length} workshops na sua pesquisa.`}</span>
<motion.h2 variants={pageTitleVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={pageTitleTransition} className={`${styles.title} text-start`}>Workshops</motion.h2>
<motion.span variants={slideFromTopVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromTopTransition} className="text-muted text-start mt-0">{workshops.length === 1 ? `Foi encontrado ${workshops.length} workshop na sua pesquisa.` : `Foram encontrados ${workshops.length} workshops na sua pesquisa.`}</motion.span>
{workshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative" key={workshop.id}>
<motion.div variants={slideFromBottomVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromBottomTransition} className="col-12 col-sm-6 col-lg-4 p-2 position-relative" key={workshop.id}>
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
@@ -136,7 +139,7 @@ export default function Search() {
</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>
</motion.div>
))}
</div>
) : null}

View File

@@ -168,6 +168,8 @@ export default function Video() {
<Link to={`/video/${previousVideo}`} className={`${styles.previousButton} fs-6 text-start`}><b><LuChevronLeft size={25} title="Vídeo anterior" /> Anterior</b></Link>
)}
<div></div>
{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>
)}

View File

@@ -12,6 +12,24 @@ import { useDebounce } from "../../../hooks/useDebounce";
import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { AnimatePresence, motion } from "framer-motion";
import {
cardInViewTransition,
cardInViewVariants,
dropdownTransition,
dropdownVariants,
fadeTransition,
fadeVariants,
listContentTransition,
listContentVariants,
pageTitleTransition,
pageTitleVariants,
slideFromLeftTransition,
slideFromRightOnMountTransition,
slideFromRightTransition,
slideFromRightVariants,
slideFromLeftVariants,
viewportOnce,
} from "../../../utils/pageMotionVariants";
export default function Videos() {
const [loading, setLoading] = useState(true);
@@ -28,6 +46,7 @@ export default function Videos() {
const [loadingVideos, setLoadingVideos] = useState(false);
const videosToShow = videos;
const [role, setRole] = useState(0);
const listContentKey = `${currentPage}-${debouncedSearch}-${selectedCategoryId}`;
useEffect(() => {
const fetchVideos = async () => {
@@ -92,7 +111,7 @@ export default function Videos() {
return (
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
<h1 className={`${styles.title} mt-1`}>Vídeos</h1>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={`${styles.title} mt-1`}>Vídeos</motion.h1>
{error && (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
@@ -101,14 +120,14 @@ export default function Videos() {
)}
<div className="row py-3 g-4 justify-content-between">
<div className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2">
<motion.div variants={slideFromLeftVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromLeftTransition} className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2">
<Form.Control type="text" placeholder="Pesquisar vídeos..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</motion.div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
<motion.div variants={slideFromRightVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromRightTransition} className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
<div
className="btn-group flex-grow-1 position-relative"
onMouseEnter={() => setShowFilterDropdown(true)}
@@ -126,10 +145,11 @@ export default function Videos() {
<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" }}
variants={dropdownVariants}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
className="dropdown-menu text-center w-100"
style={{ display: "block", zIndex: 4000, top: "100%", marginTop: "0.25rem" }}
>
@@ -204,27 +224,37 @@ export default function Videos() {
)}
</AnimatePresence>
</div>
</div>
</motion.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">
<motion.div variants={slideFromRightVariants} initial="initial" animate="animate" transition={slideFromRightOnMountTransition} 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>
</motion.div>
)}
</div>
<div className={`${styles.containerVideos} mt-3`}>
<div className="row g-3 p-0">
{ loadingVideos ? (
<div className="col-12 text-center mt-5">
<AnimatePresence mode="wait">
{loadingVideos ? (
<motion.div key="loading" variants={fadeVariants} initial="initial" animate="animate" exit="exit" transition={fadeTransition} className="col-12 text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
) : videosToShow.length > 0 ? (
</motion.div>
) : (
<motion.div
key={listContentKey}
variants={listContentVariants}
initial="initial"
animate="animate"
exit="exit"
transition={listContentTransition}
className="row g-3 p-0"
>
{videosToShow.length > 0 ? (
<div>
<div className="row g-3 p-0">
{videos.map((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}>
<motion.div key={video.id} variants={cardInViewVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={cardInViewTransition} className="col-12 col-sm-6 col-lg-4 p-2">
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black`}>
<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>
@@ -243,7 +273,7 @@ export default function Videos() {
{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>
</motion.div>
))}
</div>
<div>
@@ -287,11 +317,9 @@ export default function Videos() {
)}
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)

View File

@@ -11,6 +11,24 @@ import Swal from "sweetalert2";
import { Form, Pagination } from "react-bootstrap";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
import { AnimatePresence, motion } from "framer-motion";
import {
cardInViewTransition,
cardInViewVariants,
dropdownTransition,
dropdownVariants,
fadeTransition,
fadeVariants,
listContentTransition,
listContentVariants,
pageTitleTransition,
pageTitleVariants,
slideFromLeftTransition,
slideFromLeftVariants,
slideFromRightOnMountTransition,
slideFromRightTransition,
slideFromRightVariants,
viewportOnce,
} from "../../../utils/pageMotionVariants";
export default function Workshops() {
const [loading, setLoading] = useState(true);
@@ -25,6 +43,7 @@ export default function Workshops() {
const [loadingWorkshops, setLoadingWorkshops] = useState(false);
const [role, setRole] = useState(0);
const [userId, setUserId] = useState(0);
const listContentKey = `${currentPage}-${debouncedSearch}-${selectedWorkshopStatus}`;
/* const [searchLoading, setSearchLoading] = useState(false); */
useEffect(() => {
@@ -152,17 +171,17 @@ export default function Workshops() {
}
return (
<div className={`${styles.container} p-4 p-lg-0`}>
<h1 className={`${styles.title} mt-1 mb-0 mb-sm-3`}>Workshops</h1>
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
<motion.h1 variants={pageTitleVariants} initial="initial" animate="animate" transition={pageTitleTransition} className={`${styles.title} mt-1 mb-0 mb-sm-3`}>Workshops</motion.h1>
<div className="row py-3 g-4 justify-content-between">
<div className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2">
<motion.div variants={slideFromLeftVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromLeftTransition} className="col-12 col-sm-7 col-md-8 col-lg-6 d-flex gap-2 text-start px-2">
<Form.Control type="text" placeholder="Pesquisar workshops..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
</motion.div>
<motion.div variants={slideFromRightVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={slideFromRightTransition} 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> */}
<div
className="btn-group flex-grow-1 position-relative"
@@ -176,10 +195,11 @@ export default function Workshops() {
<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" }}
variants={dropdownVariants}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
className="dropdown-menu text-center w-100"
style={{ display: "block", zIndex: 4000, top: "100%", marginTop: "0.25rem" }}
>
@@ -228,23 +248,34 @@ export default function Workshops() {
)}
</AnimatePresence>
</div>
</div>
</motion.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' }}>
<motion.div variants={slideFromRightVariants} initial="initial" animate="animate" transition={slideFromRightOnMountTransition} 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>
</motion.div>
) : null}
</div>
<div className="row py-3 g-4">
<AnimatePresence mode="wait">
{loadingWorkshops ? (
<div className="col-12 text-center mt-5">
<motion.div key="loading" variants={fadeVariants} initial="initial" animate="animate" exit="exit" transition={fadeTransition} className="col-12 text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
) : workshops.length > 0 ? (
</motion.div>
) : (
<motion.div
key={listContentKey}
variants={listContentVariants}
initial="initial"
animate="animate"
exit="exit"
transition={listContentTransition}
className="row g-4 p-0 w-100 mx-0 mt-0"
>
{workshops.length > 0 ? (
<>
{workshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<motion.div key={workshop.id} variants={cardInViewVariants} initial="initial" whileInView="animate" viewport={viewportOnce} transition={cardInViewTransition} className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} d-flex flex-column text-start pb-3 h-100`}>
<div className={`${styles.thumbnailWorkshop} position-relative`}>
<img
@@ -306,7 +337,7 @@ export default function Workshops() {
) : null}
</div>
</div>
</div>
</motion.div>
))}
<div className="d-flex justify-content-center align-items-center gap-3 py-3">
<Pagination>
@@ -348,6 +379,9 @@ export default function Workshops() {
<span className="text-muted fs-5">Nenhum workshop encontrado com o filtro selecionado</span>
</div>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)

View File

@@ -0,0 +1,70 @@
import type { Transition, Variants } from "framer-motion";
export const viewportOnce = { once: true };
export const pageTitleVariants: Variants = {
initial: { opacity: 0, y: -50 },
animate: { opacity: 1, y: 0 },
};
export const slideFromLeftVariants: Variants = {
initial: { opacity: 0, x: -50 },
animate: { opacity: 1, x: 0 },
};
export const slideFromRightVariants: Variants = {
initial: { opacity: 0, x: 50 },
animate: { opacity: 1, x: 0 },
};
export const slideFromBottomVariants: Variants = {
initial: { opacity: 0, y: 70 },
animate: { opacity: 1, y: 0 },
};
export const slideFromTopVariants: Variants = {
initial: { opacity: 0, y: -50 },
animate: { opacity: 1, y: 0 },
};
export const fadeVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const cardInViewVariants: Variants = {
initial: { opacity: 0, y: 50 },
animate: { opacity: 1, y: 0 },
};
export const dropdownVariants: Variants = {
initial: { opacity: 0, y: -10, scale: 0.98 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, y: -10, scale: 0.98 },
};
export const listContentVariants: Variants = {
initial: { opacity: 0, y: 70 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 30 },
};
export const pageTitleTransition: Transition = { duration: 0.5, delay: 0.1 };
export const slideFromLeftTransition: Transition = { duration: 0.5, delay: 0.2 };
export const slideFromLeftTransitionNoDelay: Transition = { duration: 0.5 };
export const slideFromLeftOnMountTransition: Transition = { duration: 0.5, delay: 0.1 };
export const slideFromRightOnMountTransition02: Transition = { duration: 0.5, delay: 0.2 };
export const slideFromLeftInViewTransition03: Transition = { duration: 0.5, delay: 0.3 };
export const slideFromRightTransition: Transition = { duration: 0.5, delay: 0.3 };
export const slideFromRightInViewTransition04: Transition = { duration: 0.5, delay: 0.4 };
export const slideFromBottomTransition: Transition = { duration: 0.5, delay: 0.4 };
export const slideFromBottomInViewTransition: Transition = { duration: 0.5, delay: 0.1 };
export const slideFromRightOnMountTransition: Transition = { duration: 0.5, delay: 0.5 };
export const slideFromTopTransition: Transition = { duration: 0.5 };
export const slideFromTopTransitionDelayed: Transition = { duration: 0.5, delay: 0.1 };
export const cardInViewTransition: Transition = { duration: 0.5, delay: 0.1 };
export const dropdownTransition: Transition = { duration: 0.2, ease: "easeOut" };
export const listContentTransition: Transition = { duration: 0.4, ease: "easeOut" };
export const fadeTransition: Transition = { duration: 0.15 };