338 lines
21 KiB
TypeScript
338 lines
21 KiB
TypeScript
import API_URL from "../../config/api";
|
|
import styles from "./styles.module.css";
|
|
import { LuUser, LuMenu, LuSearch, LuLogOut, LuUsers, LuMail, LuGraduationCap, LuTvMinimalPlay, LuLayoutDashboard, LuCircleUser, LuPanelLeftClose, LuClock3, LuCalendar } from "react-icons/lu";
|
|
import { useEffect, useState } from "react";
|
|
import { useNavigate, Link, NavLink } from "react-router";
|
|
import { Button, Offcanvas } from "react-bootstrap";
|
|
import type { Workshop } from "../../types";
|
|
import type { Video } from "../../types";
|
|
import { CgSpinner } from "react-icons/cg";
|
|
import { useDebounce } from "../../hooks/useDebounce";
|
|
import { useGetVideos } from "../../hooks/useGetVideos";
|
|
import { useGetVideosSearch } from "../../hooks/useGetVideosSearch";
|
|
import { PiCheckCircleFill } from "react-icons/pi";
|
|
import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch";
|
|
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useGetCurrentUser } from "../../hooks/useGetCurrentUser";
|
|
import AnimatedCounter from "../animatedNumberCount";
|
|
|
|
export default function Header() {
|
|
const [showMenu, setShowMenu] = useState(false);
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchCompleted, setSearchCompleted] = useState(false);
|
|
const [videosSearched, setVideosSearched] = useState<Video[]>([]);
|
|
const [workshopsSearched, setWorkshopsSearched] = useState<Workshop[]>([]);
|
|
const debouncedSearch = useDebounce(search, 500);
|
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
const { getVideos } = useGetVideos();
|
|
const { getVideosSearch } = useGetVideosSearch();
|
|
const { getWorkshopsSearch } = useGetWorkshopsSearch();
|
|
const { getCurrentUser } = useGetCurrentUser();
|
|
const [videosWatched, setVideosWatched] = useState(0);
|
|
const [videosCount, setVideosCount] = useState(0);
|
|
const [role, setRole] = useState(0);
|
|
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
const fetchCurrentUser = async () => {
|
|
const currentUser = await getCurrentUser();
|
|
setRole(currentUser.data.role_id);
|
|
setVideosWatched(currentUser.data.videosWatched);
|
|
setVideosCount(currentUser.data.videosCount);
|
|
};
|
|
fetchCurrentUser();
|
|
}, []);
|
|
|
|
const handleCloseMenu = () => setShowMenu(false);
|
|
const handleShowMenu = () => setShowMenu(true);
|
|
const handleCloseSearch = () => setShowSearch(false);
|
|
const handleShowSearch = () => setShowSearch(true);
|
|
|
|
useEffect(() => {
|
|
|
|
if (debouncedSearch.trim() === "") {
|
|
setVideosSearched([]);
|
|
setWorkshopsSearched([]);
|
|
setLoading(false);
|
|
setSearchCompleted(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setSearchCompleted(false);
|
|
|
|
const fetchAll = async () => {
|
|
|
|
try {
|
|
|
|
const [_videosData, videosSearchedData, workshopsData] = await Promise.all([
|
|
getVideos({ page: 1 }),
|
|
getVideosSearch(debouncedSearch),
|
|
getWorkshopsSearch(debouncedSearch),
|
|
]);
|
|
|
|
if ("videos" in videosSearchedData) {
|
|
setVideosSearched(videosSearchedData.videos);
|
|
} else {
|
|
setVideosSearched([]);
|
|
}
|
|
|
|
if ("workshops" in workshopsData) {
|
|
setWorkshopsSearched(workshopsData.workshops);
|
|
} else {
|
|
setWorkshopsSearched([]);
|
|
}
|
|
|
|
setSearchCompleted(true);
|
|
|
|
} catch (e) {
|
|
|
|
setVideosSearched([]);
|
|
setWorkshopsSearched([]);
|
|
setSearchCompleted(true);
|
|
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchAll();
|
|
|
|
}, [debouncedSearch]);
|
|
|
|
function handleSearch(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
const q = search.trim();
|
|
|
|
if (q === "") {
|
|
setShowSearch(false);
|
|
return;
|
|
}
|
|
|
|
navigate(`/search?q=${encodeURIComponent(q)}`);
|
|
setShowSearch(false);
|
|
}
|
|
|
|
return (
|
|
<header className={`${styles.header} d-flex justify-content-between justify-content-xl-end align-items-center ps-4`} >
|
|
|
|
<>
|
|
<button type="button" className={`${styles.sidebarOpen} align-items-center justify-content-center d-xl-none`} onClick={handleShowMenu}>
|
|
<LuMenu size={30} />
|
|
</button>
|
|
|
|
<Offcanvas show={showMenu} onHide={handleCloseMenu} responsive="xl" className="d-xl-none">
|
|
<Offcanvas.Header >
|
|
<Offcanvas.Title className={"w-100 d-flex justify-content-between"}>
|
|
<Link className={`${styles.logoLink} justify-content-start`} to="/dashboard">
|
|
<img
|
|
className={styles.logo}
|
|
src="/src/assets/logo.png"
|
|
alt="Logo"
|
|
onClick={handleCloseMenu}
|
|
/>
|
|
</Link>
|
|
<button type="button" className={`${styles.sidebarClose} align-items-center justify-content-center d-xl-none`} onClick={handleCloseMenu}> <LuPanelLeftClose size={24} /> </button>
|
|
</Offcanvas.Title>
|
|
</Offcanvas.Header>
|
|
|
|
<Offcanvas.Body className="d-flex flex-column">
|
|
<nav className={`${styles.nav} flex-grow-1`}>
|
|
<ul className={styles.navList}>
|
|
<li className={`${styles.navItem} text-start`}>
|
|
<NavLink className={styles.navLink} to="/dashboard" onClick={handleCloseMenu}> <LuLayoutDashboard className="me-2" size={24} />Dashboard</NavLink>
|
|
</li>
|
|
<li className={`${styles.navItem} text-start`}>
|
|
<NavLink className={styles.navLink} to="/videos" onClick={handleCloseMenu}> <LuTvMinimalPlay className="me-2" size={24} />Videos</NavLink>
|
|
</li>
|
|
<li className={`${styles.navItem} text-start`}>
|
|
<NavLink className={styles.navLink} to="/workshops" onClick={handleCloseMenu}> <LuGraduationCap className="me-2" size={24} />Workshops</NavLink>
|
|
</li>
|
|
{role === 1 && (
|
|
<li className={`${styles.navItem} text-start`}>
|
|
<NavLink className={styles.navLink} to="/admin/users" onClick={handleCloseMenu}> <LuUsers className="me-2" size={24} />Utilizadores</NavLink>
|
|
</li>
|
|
)}
|
|
<li className={`${styles.navItem} text-start`}>
|
|
<NavLink className={styles.navLink} to="/contactos" onClick={handleCloseMenu}> <LuMail className="me-2" size={24} />Contactos</NavLink>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
|
|
{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`}><AnimatedCounter value={videosWatched} />/<AnimatedCounter value={videosCount} /></span>
|
|
</div>
|
|
)}
|
|
|
|
<div className={`${styles.logoutWrapper} text-center`}>
|
|
<Link className={styles.logoutButton} to="/logout"> <LuLogOut className="me-2" size={24} />Logout</Link>
|
|
</div>
|
|
</Offcanvas.Body>
|
|
</Offcanvas>
|
|
</>
|
|
|
|
<nav className="navbar px-3">
|
|
<div className={styles.headerRight}>
|
|
{role !== 1 && videosCount > 0 && (
|
|
<>
|
|
<div className="d-none d-sm-flex align-items-center gap-1 text-muted">
|
|
<span className="fw-semibold">Vídeos assistidos</span>
|
|
<span className={`${styles.badge} badge align-content-center`}><AnimatedCounter value={videosWatched} />/<AnimatedCounter value={videosCount} /></span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="">
|
|
<Button className={`${styles.searchButton} me-2`} variant="link" onClick={handleShowSearch}>
|
|
<LuSearch size={30} />
|
|
</Button>
|
|
<Offcanvas show={showSearch} onHide={handleCloseSearch} placement="top" style={{ height: "fit-content" }}>
|
|
<Offcanvas.Header className={`${styles.offcanvasHeader} position-relative d-flex flex-column py-4 text-start`} closeButton>
|
|
<Offcanvas.Title className="align-self-start mb-3">Efetuar pesquisa</Offcanvas.Title>
|
|
<form onSubmit={handleSearch} className="mb-0 w-100">
|
|
<div className="input-group mb-2">
|
|
<input type="text" className="form-control border-1" placeholder="Digite para pesquisar"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value);
|
|
}} />
|
|
<button type="submit" className="btn btn-primary">Pesquisar</button>
|
|
</div>
|
|
|
|
</form>
|
|
</Offcanvas.Header>
|
|
<Offcanvas.Body className="py-0">
|
|
{loading ? (
|
|
<div className="d-flex align-items-center gap-2 my-3">
|
|
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
|
|
<span>A procurar...</span>
|
|
</div>
|
|
|
|
) : (
|
|
<>
|
|
{(videosSearched.length > 0 || workshopsSearched.length > 0) && searchCompleted ? (
|
|
<>
|
|
|
|
{videosSearched.length > 0 ? (
|
|
<div className="d-flex flex-column">
|
|
<span className="fs-1 text-center fw-bold mb-1 mt-3">Vídeos</span>
|
|
|
|
<div className="row">
|
|
{videosSearched.length > 0 && (
|
|
<span className="text-muted text-start d-block">Vídeos encontrados: <AnimatedCounter value={videosSearched.length} /></span>
|
|
)}
|
|
|
|
{videosSearched.map((video) => (
|
|
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
|
|
<Link to={`/video/${video.id}`} className={`${styles.linkVideo} text-decoration-none text-black pb-2`} key={video.id} onClick={handleCloseSearch}>
|
|
<div className={`${styles.boxVideo} position-relative`} >
|
|
<img
|
|
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
|
|
src={`${API_URL}/storage/${video.thumbnail}`}
|
|
alt={video.title}
|
|
style={imageSkeletonFadeStyle}
|
|
onLoad={onImageSkeletonLoad}
|
|
/>
|
|
<div className={`${styles.boxVideoInfo} px-3 py-2 text-start text-truncate position-absolute bottom-0`}>
|
|
<h2 className={`${styles.titleVideo} mb-1`}>{video.title}</h2>
|
|
</div>
|
|
{video.watched && <span className="d-block badge text-success text-start fs-6 position-absolute top-0 start-0 bg-success-subtle text-success px-3 py-2" style={{ borderRadius: "0 0 var(--border-radius) 0" }}><PiCheckCircleFill className="mb-1 me-1" /> Visto</span>}
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
))}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
) : null}
|
|
|
|
{workshopsSearched.length > 0 ? (
|
|
<div 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 && (
|
|
<span className="text-muted text-start d-block">Workshops encontrados: {workshopsSearched.length}</span>
|
|
)}
|
|
{workshopsSearched.map((workshop) => (
|
|
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 mt-0 p-2 position-relative">
|
|
<div className={`${styles.boxWorkshop} text-start pb-3`}>
|
|
<Link to={`/workshop/${workshop.id}`} className="text-decoration-none text-black" onClick={handleCloseSearch}>
|
|
<div className={`${styles.thumbnailWorkshop} position-relative`}>
|
|
<img
|
|
src={`${API_URL}/storage/${workshop.image}`}
|
|
alt={workshop.title}
|
|
className={styles.thumbnailWorkshop}
|
|
style={imageSkeletonFadeStyle}
|
|
onLoad={onImageSkeletonLoad}
|
|
/>
|
|
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
|
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
|
<p className={`${styles.timeWorkshop}`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="px-3">
|
|
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
|
|
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3">
|
|
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
|
|
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
|
|
</div>
|
|
</div>
|
|
<button type="button" className={`${styles.linkWorkshop} btn text-center mt-3 py-2 px-5 mx-auto text-decoration-none`}>
|
|
Detalhes
|
|
</button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
|
|
) : (videosSearched.length === 0 && workshopsSearched.length === 0) && searchCompleted ? (
|
|
<div className="d-flex flex-column">
|
|
<span className="text-muted text-start my-3">Sem resultados</span>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
</Offcanvas.Body>
|
|
</Offcanvas>
|
|
</div>
|
|
|
|
<div className="btn-group" onMouseEnter={() => setShowDropdown(true)} onMouseLeave={() => setShowDropdown(false)}>
|
|
<button type="button" className={`${styles.headerDropdownToggle} btn dropdown-toggle`} >
|
|
<LuUser size={30} />
|
|
</button>
|
|
|
|
<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" }}
|
|
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>
|
|
<li><Link className="dropdown-item mt-2" to="/logout" style={{ color: "var(--text-primary-color)" }}> <LuLogOut className="mb-1 me-2" size={24} />Sair</Link></li>
|
|
</motion.ul>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</header >
|
|
);
|
|
} |