feat: paginate workshops and videos pages
This commit is contained in:
@@ -8,14 +8,17 @@ import type { Video } from "../../types";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { useGetVideos } from "../../hooks/useGetVideos";
|
||||
import { useGetWorkshops } from "../../hooks/useGetWorkshops";
|
||||
import { usePreloadImages } from "../../hooks/usePreloadImages";
|
||||
import { useGetVideosLength } from "../../hooks/useGetVideosLength";
|
||||
import { useGetVideosSearch } from "../../hooks/useGetVideosSearch";
|
||||
import { PiCheckCircleFill } from "react-icons/pi";
|
||||
import { useGetWorkshopsSearch } from "../../hooks/useGetWorkshopsSearch";
|
||||
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../utils/imageSkeleton";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function Header() {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchCompleted, setSearchCompleted] = useState(false);
|
||||
const [videosSearched, setVideosSearched] = useState<Video[]>([]);
|
||||
@@ -23,18 +26,37 @@ export default function Header() {
|
||||
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
|
||||
const isAdmin = user.role_id === 1;
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const { getVideos } = useGetVideos();
|
||||
const { getWorkshops } = useGetWorkshops();
|
||||
const { preloadImages } = usePreloadImages();
|
||||
const { getVideosLength } = useGetVideosLength();
|
||||
const { getVideosSearch } = useGetVideosSearch();
|
||||
const { getWorkshopsSearch } = useGetWorkshopsSearch();
|
||||
const [videosStats, setVideosStats] = useState({
|
||||
videos: 0,
|
||||
videosWatched: 0
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) return;
|
||||
|
||||
const fetchProgressVideos = async () => {
|
||||
const videosLengthData = await getVideosLength();
|
||||
if ("videos" in videosLengthData) {
|
||||
setVideosStats(videosLengthData as { videos: number, videosWatched: number });
|
||||
}
|
||||
};
|
||||
fetchProgressVideos();
|
||||
}, []);
|
||||
|
||||
const handleCloseMenu = () => setShowMenu(false);
|
||||
const handleShowMenu = () => setShowMenu(true);
|
||||
const handleCloseSearch = () => setShowSearch(false);
|
||||
const handleShowSearch = () => setShowSearch(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (debouncedSearch.trim() === "") {
|
||||
setVideosSearched([]);
|
||||
setWorkshopsSearched([]);
|
||||
@@ -47,40 +69,42 @@ export default function Header() {
|
||||
setSearchCompleted(false);
|
||||
|
||||
const fetchAll = async () => {
|
||||
|
||||
try {
|
||||
const [videosData, workshopsData] = await Promise.all([
|
||||
getVideos(debouncedSearch),
|
||||
getWorkshops(debouncedSearch),
|
||||
|
||||
const [videosData, videosSearchedData, workshopsData] = await Promise.all([
|
||||
getVideos({ page: 1 }),
|
||||
getVideosSearch(debouncedSearch),
|
||||
getWorkshopsSearch(debouncedSearch),
|
||||
]);
|
||||
|
||||
if (Array.isArray(videosData)) {
|
||||
setVideosSearched(videosData);
|
||||
if ("videos" in videosSearchedData) {
|
||||
setVideosSearched(videosSearchedData.videos);
|
||||
} else {
|
||||
setVideosSearched([]);
|
||||
}
|
||||
|
||||
if (Array.isArray(workshopsData)) {
|
||||
setWorkshopsSearched(workshopsData);
|
||||
if ("workshops" in workshopsData) {
|
||||
setWorkshopsSearched(workshopsData.workshops);
|
||||
} else {
|
||||
setWorkshopsSearched([]);
|
||||
}
|
||||
|
||||
await preloadImages([
|
||||
...(videosSearched as Video[]).map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
|
||||
...(workshopsSearched as Workshop[]).map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
|
||||
]);
|
||||
|
||||
setSearchCompleted(true);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
setVideosSearched([]);
|
||||
setWorkshopsSearched([]);
|
||||
setSearchCompleted(true);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
|
||||
}, [debouncedSearch]);
|
||||
|
||||
function handleSearch(event: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -126,19 +150,21 @@ export default function Header() {
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/workshops"> <LuGraduationCap className="me-2" size={24} />Workshops</NavLink>
|
||||
</li>
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/admin/users"> <LuUsers className="me-2" size={24} />Utilizadores</NavLink>
|
||||
</li>
|
||||
{isAdmin && (
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/admin/users"> <LuUsers className="me-2" size={24} />Utilizadores</NavLink>
|
||||
</li>
|
||||
)}
|
||||
<li className={`${styles.navItem} text-start`}>
|
||||
<NavLink className={styles.navLink} href="/contactos"> <LuMail className="me-2" size={24} />Contactos</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{!isAdmin && (
|
||||
{!isAdmin && videosStats.videos > 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`}>0/{videos.filter((video) => video.is_active).length}</span>
|
||||
<span className={`${styles.badge} badge align-content-center ms-3`}>{videosStats.videosWatched}/{videosStats.videos}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -151,14 +177,12 @@ export default function Header() {
|
||||
|
||||
<nav className="navbar px-3">
|
||||
<div className={styles.headerRight}>
|
||||
{!isAdmin && (
|
||||
{!isAdmin && videosStats.videos > 0 && (
|
||||
<>
|
||||
{videos.length > 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`}>0/{videos.filter((video) => video.is_active).length}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-none d-sm-flex align-items-center gap-1 text-muted">
|
||||
<span className="fw-semibold">Vídeos assistidos</span>
|
||||
<span className={`${styles.badge} badge align-content-center`}>{videosStats.videosWatched}/{videosStats.videos}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -206,11 +230,17 @@ export default function Header() {
|
||||
<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={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
|
||||
<img
|
||||
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
|
||||
src={`http://127.0.0.1:8000/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>
|
||||
<span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span>
|
||||
</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>
|
||||
@@ -232,8 +262,14 @@ export default function Header() {
|
||||
<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="position-relative">
|
||||
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
|
||||
<div className={`${styles.thumbnailWorkshop} position-relative`}>
|
||||
<img
|
||||
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
|
||||
alt={workshop.title}
|
||||
className={styles.thumbnailWorkshop}
|
||||
style={imageSkeletonFadeStyle}
|
||||
onLoad={onImageSkeletonLoad}
|
||||
/>
|
||||
<div className="position-absolute w-100 bottom-0 justify-content-evenly d-none d-md-flex d-xl-none d-xxl-flex mb-2">
|
||||
<p className={`${styles.dateWorkshop}`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</p>
|
||||
<p className={`${styles.timeWorkshop}`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5)} - {workshop.time_end.slice(0, 5)}</p>
|
||||
@@ -241,7 +277,6 @@ export default function Header() {
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<h2 className={`${styles.titleWorkshop} d-block text-start mt-3`}>{workshop.title}</h2>
|
||||
<p className={`${styles.descriptionWorkshop} text-truncate text-muted mt-0 mb-2`}>{workshop.description}</p>
|
||||
<div className="position-relative d-flex flex-column d-md-none d-xl-flex d-xxl-none mt-3">
|
||||
<span className={`${styles.dateWorkshopMobile} text-start mb-1`}><LuCalendar className={`${styles.icon} mb-1 me-2`} />{workshop.date.split('-').reverse().join('-')}</span>
|
||||
<span className={`${styles.timeWorkshopMobile} text-start`}><LuClock3 className={`${styles.icon} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(':').join('h')} - {workshop.time_end.slice(0, 5).split(':').join('h')}</span>
|
||||
@@ -274,14 +309,25 @@ export default function Header() {
|
||||
</Offcanvas>
|
||||
</div>
|
||||
|
||||
<div className="btn-group">
|
||||
<button type="button" className={`${styles.headerDropdownToggle} btn dropdown-toggle`} data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div className="btn-group" onMouseEnter={() => setShowDropdown(true)} onMouseLeave={() => setShowDropdown(false)}>
|
||||
<button type="button" className={`${styles.headerDropdownToggle} btn dropdown-toggle`} >
|
||||
<LuUser size={30} />
|
||||
</button>
|
||||
<ul className="dropdown-menu dropdown-menu-end">
|
||||
<li><Link className="dropdown-item" to="/profile"><LuCircleUser className="mb-1 me-2" size={24} />A minha conta</Link></li>
|
||||
<li><a className="dropdown-item mt-2" href="/logout" style={{ color: "var(--text-primary-color)" }}> <LuLogOut className="mb-1 me-2" size={24} />Sair</a></li>
|
||||
</ul>
|
||||
|
||||
<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><a className="dropdown-item mt-2" href="/logout" style={{ color: "var(--text-primary-color)" }}> <LuLogOut className="mb-1 me-2" size={24} />Sair</a></li>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user