Estado inicial: frontend React + backend Laravel

This commit is contained in:
Xavier Oliveira
2026-05-15 16:13:33 +01:00
commit 41c5f87d5b
216 changed files with 29916 additions and 0 deletions
@@ -0,0 +1,290 @@
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 } from "react-router";
import { Button, NavLink, Offcanvas } from "react-bootstrap";
import type { User, 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 { useGetWorkshops } from "../../hooks/useGetWorkshops";
import { usePreloadImages } from "../../hooks/usePreloadImages";
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[]>([]);
const [workshopsSearched, setWorkshopsSearched] = useState<Workshop[]>([]);
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
const isAdmin = user.role_id === 1;
const debouncedSearch = useDebounce(search, 500);
const { getVideos } = useGetVideos();
const { getWorkshops } = useGetWorkshops();
const { preloadImages } = usePreloadImages();
const navigate = useNavigate();
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, workshopsData] = await Promise.all([
getVideos(debouncedSearch),
getWorkshops(debouncedSearch),
]);
if (Array.isArray(videosData)) {
setVideosSearched(videosData);
} else {
setVideosSearched([]);
}
if (Array.isArray(workshopsData)) {
setWorkshopsSearched(workshopsData);
} 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>) {
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" />
</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} href="/dashboard"> <LuLayoutDashboard className="me-2" size={24} />Dashboard</NavLink>
</li>
<li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/videos"> <LuTvMinimalPlay className="me-2" size={24} />Videos</NavLink>
</li>
<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>
<li className={`${styles.navItem} text-start`}>
<NavLink className={styles.navLink} href="/contactos"> <LuMail className="me-2" size={24} />Contactos</NavLink>
</li>
</ul>
</nav>
{!isAdmin && (
<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>
</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}>
{!isAdmin && (
<>
{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="">
<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: {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={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
<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>
</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="position-relative">
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={styles.thumbnailWorkshop} />
<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>
<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>
</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">
<button type="button" className={`${styles.headerDropdownToggle} btn dropdown-toggle`} data-bs-toggle="dropdown" aria-expanded="false">
<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>
</div>
</div>
</nav>
</header >
);
}