feat: paginate workshops and videos pages

This commit is contained in:
Xavier Oliveira
2026-05-27 09:24:10 +01:00
parent da0baaee15
commit 68f99798ce
72 changed files with 3352 additions and 1044 deletions

View File

@@ -0,0 +1,39 @@
import { useEffect } from 'react';
import '@n8n/chat/style.css';
import { createChat } from '@n8n/chat';
import './n8n-chat-theme.css';
export default function N8nChat() {
useEffect(() => {
const style = document.createElement('style');
document.head.appendChild(style);
const app = createChat({
webhookUrl: 'https://n8n.dev.livetech.pt/webhook/b29d21a8-9fbd-4cdd-810f-30470bffe32e',
target: '#n8n-chat',
mode: 'window',
initialMessages: ['Olá! 👋 Como posso ajudar?'],
i18n: {
en: {
title: 'Assistente AI',
subtitle: '',
footer: '',
getStarted: 'Nova conversa',
inputPlaceholder: 'Escreva a sua pergunta...',
closeButtonTooltip: 'Fechar',
},
},
});
return () => {
app.unmount();
};
}, []);
return (
<div
id="n8n-chat"
style={{ color: 'var(--text-primary-color)', textAlign: 'start'}}
/>
);
}

View File

@@ -0,0 +1,51 @@
:root {
--chat--header--background: var(--primary-color);
--chat--header--color: var(--text-white);
--chat--header--title-size: 22px;
--chat--body--background: var(--bg-white);
--chat--color-primary: var(--primary-color);
--chat--color-primary-shade-50: var(--primary-color-dark);
--chat--message--user--background: var(--bg-primary-color-opacity);
--chat--message--user--color: var(--text-primary-color);
--chat--message--bot--background: var(--bg-grey);
--chat--message--bot--color: var(--text-black);
--chat--input--border-color-active: var(--shadow-primary);
--chat--color-disabled: var(--primary-color);
--chat--input--text-color: var(--text-primary-color);
--chat--heading--font-size: 22px;
--chat--border-radius: var(--border-radius);
--chat--message--border-radius: var(--border-radius);
--chat--message--border-color: var(--shadow-primary);
--chat--input--send--button--color: var(--primary-color);
--chat--toggle--background: var(--primary-color);
--chat--toggle--border: 2px solid var(--primary-color);
}
.chat-window-wrapper .chat-window-toggle {
border: var(--chat--toggle--border) !important;
margin-right: 10px;
margin-bottom: 10px;
transition: all 0.3s ease;
}
.chat-window-wrapper .chat-window-toggle:hover {
background-color: var(--bg-grey) !important;
color: var(--text-primary-color) !important;
}
.chat-layout .chat-header h1 {
margin: 0 !important;
letter-spacing: 1px !important;
}
.chat-inputs textarea {
color: var(--text-black) !important;
align-self: center;
}
.chat-message a {
color: var(--primary-color) !important;
text-decoration: underline !important;
pointer-events: auto !important;
cursor: pointer !important;
}

View File

@@ -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>

View File

@@ -114,12 +114,12 @@
width: 200px;
text-decoration: none;
color: var(--text-white);
background-color: var(--primary-color);
border-radius: 8px;
background: var(--bg-gradient);
box-shadow: var(--shadow-primary);
border-radius: var(--border-radius-button);
padding: 10px 12px;
font-weight: 600;
transition: all 0.3s ease;
&:hover {
background-color: var(--secondary-color);
}
@@ -174,6 +174,9 @@
}
.boxVideo {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
@@ -181,7 +184,12 @@
}
.boxVideo::after {
content: '';
content: '\F4F4';
color: var(--bg-grey);
font-size: 4rem;
align-content: center;
text-align: center;
font-family: 'bootstrap-icons';
position: absolute;
top: 0;
left: 0;
@@ -189,6 +197,11 @@
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
transition: all 0.3s ease;
}
.boxVideo:hover::after {
color: var(--bg-primary-color);
}
.boxVideoInfo{
@@ -268,12 +281,17 @@
}
.thumbnailWorkshop{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
height: 150px;
position: relative;
overflow: hidden;
}
.icon{
@@ -284,6 +302,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin {
from {
transform: rotate(0deg);

View File

@@ -91,7 +91,7 @@
text-decoration: none;
color: var(--text-white);
background-color: var(--primary-color);
border-radius: 8px;
border-radius: var(--border-radius-button);
padding: 10px 12px;
font-weight: 600;
transition: all 0.3s ease;

View File

@@ -1,22 +1,42 @@
import type { ApiErrorResponse, Video } from "../types";
type GetVideosParams = {
page?: number;
search?: string;
category?: string;
status?: "active" | "inactive";
watched?: 0 | 1;
};
export function useGetVideos() {
async function getVideos(searchQuery?: string) {
const response = await fetch(`http://127.0.0.1:8000/api/videos?search=${encodeURIComponent(searchQuery ?? "")}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
async function getVideos(params: GetVideosParams) {
const query = new URLSearchParams();
if (params.page !== undefined) query.append("page", params.page.toString());
if (params.search) query.append("search", params.search);
if (params.category) query.append("category", params.category);
if (params.status) query.append("status", params.status);
if (params.watched !== undefined) query.append("watched", params.watched.toString());
const response = await fetch(`http://127.0.0.1:8000/api/videos?${query.toString()}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
}
);
const data = await response.json();
if (response.ok) {
return data.data as Video[];
return {
videos: data.data as Video[],
meta: data.meta
};
} else {
return (data as ApiErrorResponse);
return data as ApiErrorResponse;
}
}

View File

@@ -0,0 +1,32 @@
import type { ApiErrorResponse } from "../types";
export function useGetVideosLength() {
async function getVideosLength() {
try {
const response = await fetch("http://127.0.0.1:8000/api/videos-length", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
return {
videos: data.data.videos,
videosWatched: data.data.videosWatched,
};
}
return data as ApiErrorResponse;
} catch (error) {
return error as ApiErrorResponse;
}
}
return { getVideosLength };
}

View File

@@ -0,0 +1,30 @@
import type { ApiErrorResponse, Video } from "../types";
export function useGetVideosSearch() {
async function getVideosSearch(searchQuery: string = "") {
const response = await fetch(
`http://127.0.0.1:8000/api/videos-search?search=${encodeURIComponent(searchQuery)}`,
{
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
}
);
const data = await response.json();
if (response.ok) {
return {
videos: data.data as Video[],
meta: data.meta
};
} else {
return data as ApiErrorResponse;
}
}
return { getVideosSearch };
}

View File

@@ -1,8 +1,22 @@
import type { ApiErrorResponse, Workshop } from "../types";
type GetWorkshopsParams = {
search?: string;
status?: string;
page?: number;
per_page?: number;
};
export function useGetWorkshops() {
async function getWorkshops(searchQuery?: string) {
const response = await fetch(`http://127.0.0.1:8000/api/workshops?search=${encodeURIComponent(searchQuery ?? "")}`, {
async function getWorkshops(params: GetWorkshopsParams) {
const query = new URLSearchParams();
if (params.search) query.append("search", params.search);
if (params.status) query.append("status", params.status);
if (params.page) query.append("page", params.page.toString());
if (params.per_page) query.append("per_page", params.per_page.toString());
const response = await fetch(`http://127.0.0.1:8000/api/workshops?${query.toString()}`, {
method: "GET",
headers: {
Accept: "application/json",
@@ -14,7 +28,10 @@ export function useGetWorkshops() {
const data = await response.json();
if (response.ok) {
return data.data as Workshop[];
return {
workshops: data.data as Workshop[],
meta: data.meta
};
} else {
return (data as ApiErrorResponse);
}

View File

@@ -0,0 +1,32 @@
import type { ApiErrorResponse } from "../types";
export function useGetWorkshopsLength() {
async function getWorkshopsLength() {
try {
const response = await fetch("http://127.0.0.1:8000/api/workshops-length", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
return {
workshops: data.data.workshops,
workshopsInscribed: data.data.workshopsInscribed,
};
}
return data as ApiErrorResponse;
} catch (error) {
return error as ApiErrorResponse;
}
}
return { getWorkshopsLength };
}

View File

@@ -0,0 +1,30 @@
import type { ApiErrorResponse, Workshop } from "../types";
export function useGetWorkshopsSearch() {
async function getWorkshopsSearch(searchQuery: string = "") {
const response = await fetch(
`http://127.0.0.1:8000/api/workshops-search?search=${encodeURIComponent(searchQuery)}`,
{
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
}
);
const data = await response.json();
if (response.ok) {
return {
workshops: data.data as Workshop[],
meta: data.meta
};
} else {
return data as ApiErrorResponse;
}
}
return { getWorkshopsSearch };
}

View File

@@ -0,0 +1,23 @@
import type { ApiErrorResponse, Video } from "../types";
export function useNextVideos() {
async function getNextVideos() {
const response = await fetch("http://127.0.0.1:8000/api/next-videos", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
return {
videos: data.data as Video[],
};
} else {
return data as ApiErrorResponse;
}
}
return { getNextVideos };
}

View File

@@ -0,0 +1,23 @@
import type { ApiErrorResponse, NextWorkshopsResponse } from "../types";
export function useNextWorkshops() {
async function getNextWorkshops() {
const response = await fetch("http://127.0.0.1:8000/api/next-workshops", {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
return {
workshops: data.data as NextWorkshopsResponse[],
};
} else {
return data as ApiErrorResponse;
}
}
return { getNextWorkshops };
}

View File

@@ -1,17 +0,0 @@
import { useCallback } from "react";
export function usePreloadImages() {
const preloadImages = useCallback ((urls: string[]): Promise<void[]> => {
return Promise.all(
urls.map(url => new Promise<void>((resolve) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = () => resolve();
img.src = url;
}))
);
}, []);
return { preloadImages };
}

View File

@@ -0,0 +1,29 @@
import { useState, useRef } from "react";
export function useVideoWatch(videoId: number, initialWatched: boolean) {
const [watched, setWatched] = useState(initialWatched);
const alreadySent = useRef(false);
const markAsWatched = async () => {
if (alreadySent.current || watched ) return;
alreadySent.current = true;
try {
await fetch(`http://127.0.0.1:8000/api/video/${videoId}/watch`, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
setWatched(true);
} catch (error) {
console.error("Erro ao marcar o vídeo como assistido: ", error);
alreadySent.current = false;
}
}
return { watched, markAsWatched };
}

View File

@@ -1,157 +1,164 @@
:root {
--primary-color: #B20112;
--secondary-color: #C4534A;
--tertiary-color: #0054B0;
--success-color: #08a35d;
--neutral-color: #8A716E;
--primary-contrast-color: #410002;
--primary-color: #B20112;
--secondary-color: #C4534A;
--tertiary-color: #0054B0;
--success-color: #08a35d;
--neutral-color: #8A716E;
--primary-contrast-color: #410002;
--text-primary-color: var(--primary-color);
--text-secondary-color: var(--secondary-color);
--text-tertiary-color: var(--tertiary-color);
--text-neutral-color: var(--neutral-color);
--text-primary-contrast-color: var(--primary-contrast-color);
--text-white: #ffffff;
--text-black: #000000;
--text-primary-color: var(--primary-color);
--text-secondary-color: var(--secondary-color);
--text-tertiary-color: var(--tertiary-color);
--text-neutral-color: var(--neutral-color);
--text-primary-contrast-color: var(--primary-contrast-color);
--text-white: #ffffff;
--text-black: #000000;
--size-font-title: 2.2rem;
--size-font-subtitle: 1.7rem;
--size-font-text: 1.2rem;
--size-font-small: 1rem;
--size-font-title: clamp(1.8rem, calc(1.607vw + 0.629rem), 2.2rem);
--size-font-subtitle: clamp(1.55rem, calc(1.116vw + 0.614rem), 1.7rem);
--size-font-text: clamp(1.2rem, calc(0.394vw + 0.811rem), 1.2rem);
--size-font-small: clamp(1rem, calc(0.295vw + 0.708rem), 1rem);
--border-radius: 15px;
--border-radius-input: 5px;
--border-radius-button: 10px;
--border-primary-color: var(--primary-color);
--border-secondary-color: var(--secondary-color);
--border-tertiary-color: var(--tertiary-color);
--border-neutral-color: var(--neutral-color);
--border-radius: 15px;
--border-radius-input: 5px;
--border-radius-button: 10px;
--border-primary-color: var(--primary-color);
--border-secondary-color: var(--secondary-color);
--border-tertiary-color: var(--tertiary-color);
--border-neutral-color: var(--neutral-color);
--bg-white: #ffffff;
--bg-grey: #F3F3F3;
--bg-primary-color: var(--primary-color);
--bg-primary-color-opacity: #FFEDEA;
--bg-secondary-color: var(--secondary-color);
--bg-tertiary-color: var(--tertiary-color);
--bg-tertiary-color-opacity: #CFE2FF;
--bg-success-color-opacity: #D1E7DD;
--bg-neutral-color: var(--neutral-color);
--bg-gradient: linear-gradient(135deg, #b20112 0%, #8e010e 100%);
--bg-white: #ffffff;
--bg-grey: #F3F3F3;
--bg-primary-color: var(--primary-color);
--bg-primary-color-opacity: #FFEDEA;
--bg-secondary-color: var(--secondary-color);
--bg-tertiary-color: var(--tertiary-color);
--bg-tertiary-color-opacity: #CFE2FF;
--bg-success-color-opacity: #D1E7DD;
--bg-neutral-color: var(--neutral-color);
--bg-gradient: linear-gradient(135deg, #b20112 0%, #8e010e 100%);
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:rgba(0, 0, 0, 0.1) 0 10px 15px -3px,
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--shadow-primary:0 4px 10px rgba(178, 1, 18, 0.4),
0 2px 6px rgba(142, 1, 14, 0.3);
;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--shadow-primary:0 4px 10px rgba(178, 1, 18, 0.4), 0 2px 6px rgba(142, 1, 14, 0.3);;
--font-family: 'Manrope',
sans-serif;
--font-family: 'Manrope', sans-serif;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
#social .button-icon {
filter: invert(1) brightness(2);
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: var(--bg-white);
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: var(--bg-white);
}
*{
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
font-family: var(--font-family);
box-sizing: border-box;
margin: 0;
padding: 0;
margin: 0;
font-family: var(--font-family);
box-sizing: border-box;
margin: 0;
padding: 0;
}
h1,
h2 {
font-weight: 500;
color: var(--text-h);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
margin: 0;
}
code,
.counter {
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
/* Pagination */
@@ -161,7 +168,7 @@ html body .pagination .page-item.active .page-link {
color: var(--text-primary-color) !important;
}
.page-link{
.page-link {
color: var(--text-neutral-color) !important;
}
@@ -169,10 +176,7 @@ input:focus,
textarea:focus,
select:focus,
button:focus {
outline: none !important;
box-shadow: none !important;
border: none;
}
outline: none !important;
box-shadow: none !important;
border: none;
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import {router} from './routes'
import './index.css'
import 'bootstrap-icons/font/bootstrap-icons.css';
createRoot(document.getElementById('root')!).render(

View File

@@ -1,9 +1,9 @@
import { Link, Outlet, useNavigate } from "react-router";
import { Outlet, useNavigate } from "react-router";
import styles from "./styles.module.css";
import Header from "../../components/header";
import Sidebar from "../../components/sidebar";
import Footer from "../../components/footer";
import { useEffect } from "react";
import Chatbot from "../../components/chatbot";
export default function ProtectedLayout() {
@@ -29,8 +29,11 @@ export default function ProtectedLayout() {
<main className={styles.main}>
<Outlet />
</main>
<Chatbot />
{/* <Footer /> */}
</section>
</div>
</>
);

View File

@@ -24,6 +24,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{

View File

@@ -19,6 +19,7 @@ export default function CreateVideo() {
const [checkingRole, setCheckingRole] = useState(true);
const [creating, setCreating] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [order, setOrder] = useState<number>(0);
setTimeout(() => {
setCheckingRole(false);
@@ -68,6 +69,7 @@ export default function CreateVideo() {
formData.append("url", videoFile);
formData.append("thumbnail", thumbnailFile);
formData.append("tags", tags);
formData.append("order", order.toString());
category_ids.forEach(id => {
formData.append("category_ids[]", id);
@@ -112,6 +114,7 @@ export default function CreateVideo() {
setVideoFile(null);
setThumbnailFile(null);
setTags("");
setOrder(0);
setCategoryIds([]);
setCreating(false);
Swal.fire({
@@ -248,6 +251,10 @@ export default function CreateVideo() {
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 col-sm-2 px-1 text-start">
<label className="form-label fw-bold" htmlFor="order">Ordem</label>
<input type="number" className="form-control py-2 text-truncate" id="order" name="order" placeholder="Insira a ordem do vídeo" value={order} onChange={(e) => setOrder(parseInt(e.target.value, 10))} />
</div>
<div className="col-12 px-1 text-start">
<div className="d-flex">
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>

View File

@@ -6,6 +6,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{

View File

@@ -110,22 +110,22 @@ export default function CreateWorkshop() {
<h1 className={styles.title}>Adicionar Workshop</h1>
<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>
<label className={`${styles.label} form-label fw-bold`} htmlFor="title">Título</label>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" placeholder="Insira o título do workshop" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="col-12 px-1 text-start">
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="description">Descrição</label>
<textarea rows={5} className="form-control py-2" id="description" name="description" placeholder="Insira uma descrição para o workshop" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="col-12 px-1 text-start">
<label className="form-label fw-bold" htmlFor="image">Adicionar imagem</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="image">Adicionar imagem</label>
<div className="position-relative">
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1" /> {image ? image.name : "Carregar imagem do workshop"} </span>
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="image" name="image" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setImage(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
<label className="form-label fw-bold mb-2" htmlFor="date">Data</label>
<label className={`${styles.label} form-label fw-bold mb-2`} htmlFor="date">Data</label>
<DatePicker
selected={date}
onChange={(d: Date | null) => setDate(d)}
@@ -137,7 +137,7 @@ export default function CreateWorkshop() {
/>
</div>
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
<label className="form-label fw-bold" htmlFor="time_start">Hora de início</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_start">Hora de início</label>
<DatePicker
selected={time_start}
onChange={(t: Date | null) => setTimeStart(t)}
@@ -158,7 +158,7 @@ export default function CreateWorkshop() {
/>
</div>
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
<label className="form-label fw-bold" htmlFor="time_end">Hora de término</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_end">Hora de término</label>
<DatePicker
selected={time_end}
onChange={(t: Date | null) => setTimeEnd(t)}

View File

@@ -6,6 +6,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{
@@ -17,6 +18,12 @@
font-size: var(--size-font-title);
}
.label {
color: var(--text-primary-color);
font-size: var(--size-font-small);
font-weight: 500;
}
.inputFiles{
display: block;
width: 100%;

View File

@@ -14,6 +14,7 @@ export default function editVideo() {
const [category_ids, setCategoryIds] = useState<string[]>([]);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [isActive, setIsActive] = useState<boolean>();
const [order, setOrder] = useState<number>();
const [error, setError] = useState<ApiErrorResponse | null>(null);
const navigate = useNavigate();
@@ -24,8 +25,10 @@ export default function editVideo() {
useEffect(() => {
if (!video) return;
console.log(video);
setCategoryIds(video.categories?.map((category) => category.id.toString()) ?? []);
setIsActive(Boolean(video.is_active));
setOrder(video.order);
}, [video]);
async function getVideo() {
@@ -58,7 +61,7 @@ export default function editVideo() {
}
async function destroy(id: number) {
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
const response = await fetch(`http://127.0.0.1:8000/api/delete-video/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
@@ -250,93 +253,93 @@ export default function editVideo() {
<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">
<form method="patch" onSubmit={update}>
<input type="hidden" name="video_id" value={video?.id} />
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="title">Título</label>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" defaultValue={video?.title} />
<div className="mt-5">
<span className={styles.title}>Alterar dados do vídeo</span>
<div 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">
<label className={`${styles.label} form-label fw-bold`} htmlFor="title">Título</label>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" defaultValue={video?.title} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className={`${styles.label} form-label fw-bold`} htmlFor="description">Descrição</label>
<textarea rows={5} className="form-control py-2" id="description" name="description" defaultValue={video?.description} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className={`${styles.label} form-label fw-bold`} htmlFor="tags">Tags <small className="text-muted">(separar por vírgulas)</small></label>
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" defaultValue={video?.tags} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className={`${styles.label} form-label fw-bold`} htmlFor="thumbnail">Atualizar thumbnail</label>
<div className="position-relative">
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1 me-2" /> {thumbnailFile ? thumbnailFile.name : "Carregar nova imagem"} </span>
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
<textarea rows={5} className="form-control py-2" id="description" name="description" defaultValue={video?.description} />
</div>
<div className="col-12 col-sm-2 px-1 text-start mb-3">
<label className={`${styles.label} form-label fw-bold`} htmlFor="order">Ordem</label>
<input type="number" className="form-control py-2 text-truncate" id="order" name="order" value={order ?? ''} onChange={(e) => setOrder(parseInt(e.target.value, 10))} />
</div>
<div className="col-12 px-1 text-start mb-3">
<div className="d-flex">
<label className={`${styles.label} form-label fw-bold align-content-center mb-0 me-2`} htmlFor="category_id">Categorias</label>
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="tags">Tags <small className="text-muted">(separar por vírgulas)</small></label>
<input type="text" className="form-control py-2 text-truncate" id="tags" name="tags" defaultValue={video?.tags} />
</div>
<div className="col-12 px-1 text-start mb-3">
<label className="form-label fw-bold" htmlFor="thumbnail">Atualizar thumbnail</label>
<div className="position-relative">
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 text-white px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}><LuUpload className="mb-1 me-2" /> {thumbnailFile ? thumbnailFile.name : "Carregar nova imagem"} </span>
<input type="file" className={`${styles.inputFiles} form-control py-2 text-truncate`} id="thumbnail" name="thumbnail" accept="image/*" style={{ width: '100%', height: '100%', cursor: 'pointer', zIndex: 1000, opacity: 0 }} onChange={(e) => setThumbnailFile(e.target.files?.[0] ?? null)} />
</div>
</div>
<div className="col-12 px-1 text-start mb-3">
<div className="d-flex">
<label className="form-label fw-bold align-content-center mb-0 me-2" htmlFor="category_id">Categorias</label>
<button className={`${styles.btnAdicionarCategoria}`} onClick={handleCreateCategory} title="Adicionar categoria"><LuPlus className="mb-1" /></button>
</div>
<div>
{categories && categories.length > 0 ? (
categories.map((category) => (
<div key={category.id}>
<input type="checkbox" name="category_ids[]" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox} />
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
</div>))
) : (
<div>
<span>Nenhuma categoria encontrada</span>
</div>
)}
<div>
{categories && categories.length > 0 ? (
categories.map((category) => (
<div key={category.id}>
<input type="checkbox" name="category_ids[]" id={`category_${category.id}`} value={category.id} className="me-2" checked={category_ids.includes(category.id.toString())} onChange={handleCheckbox} />
<label htmlFor={`category_${category.id}`} key={category.id}>{category.name}</label>
</div>))
) : (
<div>
<span>Nenhuma categoria encontrada</span>
</div>
)}
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
</div>
{/* <button className={`${styles.btnAdicionarVideo} btn mt-5`}><LuPlus className="mb-1" /> Nova categoria</button> */}
</div>
<div className="col-12 px-1 text-start d-flex flex-column mb-3">
<label className="form-label fw-bold" htmlFor="tags">Estado</label>
<div className="d-flex gap-4">
<span>
<input
type="radio"
name="is_active"
id="is_active_true"
value="1"
checked={isActive === true}
onChange={() => setIsActive(true)}
/>
<label className="ms-1" htmlFor="is_active_true">Ativo</label>
</span>
<span>
<input
type="radio"
name="is_active"
id="is_active_false"
value="0"
checked={isActive === false}
onChange={() => setIsActive(false)}
/>
<label className="ms-1" htmlFor="is_active_false">Inativo</label>
</span>
</div>
{/* <select className="form-control py-2 text-truncate" id="is_active" name="is_active" defaultValue={video?.is_active ? "Ativo" : "Inativo"} onChange={(e) => setIsActive(e.target.value)}>
<option value="1">Ativo</option>
<option value="0">Inativo</option>
</select> */}
</div>
<div className="col-12 px-1 text-start d-flex flex-column mb-3">
<label className={`${styles.label} form-label fw-bold`} htmlFor="tags">Estado</label>
<div className="d-flex gap-4">
<span>
<input
type="radio"
name="is_active"
id="is_active_true"
value="1"
checked={isActive === true}
onChange={() => setIsActive(true)}
/>
<label className="ms-1" htmlFor="is_active_true">Ativo</label>
</span>
<span>
<input
type="radio"
name="is_active"
id="is_active_false"
value="0"
checked={isActive === false}
onChange={() => setIsActive(false)}
/>
<label className="ms-1" htmlFor="is_active_false">Inativo</label>
</span>
</div>
</div>
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center" >
<button type="submit" className={`${styles.submitButton} bg-primary`}>Submeter Dados <LuCheck className="mb-1 text-white" /></button>
<div className="buttonsContainer mt-5 d-flex gap-2 justify-content-center flex-wrap" >
<button type="submit" className={`${styles.submitButton} bg-primary`}>Submeter Dados <LuCheck className="mb-1 text-white" /></button>
<button type="button" className={`${styles.deleteButton}`} onClick={() => handleDeleteVideo()}>Apagar <LuTrash2 className="mb-1 text-white" /></button>
</div>
</form>
</div>
</div>
</form>
</div>
</div>
</div>
) : (
<div className="text-center alert alert-danger mt-5 align-items-center">
<span>{error?.message}</span>

View File

@@ -6,6 +6,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{
@@ -17,6 +18,12 @@
font-size: var(--size-font-title);
}
.label {
color: var(--text-primary-color);
font-size: var(--size-font-small);
font-weight: 500;
}
.statusAlert{
width: 100px;
border-radius: var(--border-radius-input);
@@ -45,7 +52,7 @@
.btnAdicionarCategoria{
background-color: transparent;
color: var(--primary-color);
color: var(--primary-contrast-color);
border: none;
font-size: var(--size-font-subtitle);
padding: 0;
@@ -65,7 +72,7 @@
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
min-width: 205px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
@@ -81,7 +88,7 @@
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
min-width: 205px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;

View File

@@ -2,13 +2,13 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import { CgSpinner } from "react-icons/cg";
import type { ApiErrorResponse, User, Workshop } from "../../../../types";
import { LuArrowLeft, LuCalendar, LuCheck, LuClock3, LuPencil, LuTrash2, LuUpload, LuUsers, LuX, LuClipboardList } from "react-icons/lu";
import { LuArrowLeft, LuCalendar, LuCheck, LuClock3, LuPencil, LuTrash2, LuUpload, LuUsers, LuX} from "react-icons/lu";
import { pt } from "date-fns/locale";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import Swal from "sweetalert2";
import styles from "./styles.module.css";
import { PiWarningCircleLight } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
export default function Workshop() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string) as User;
@@ -113,7 +113,7 @@ export default function Workshop() {
showConfirmButton: false,
showCloseButton: true,
});
if(overrides?.status === "canceled") {
if (overrides?.status === "canceled") {
Swal.fire({
title: "Workshop cancelado com sucesso",
icon: 'error',
@@ -205,130 +205,140 @@ export default function Workshop() {
{workshop ? (
<>
<div className={`${styles.container} d-flex flex-column gap-2`}>
<span className={styles.title}>{workshop.title}</span>
<div className="d-flex flex-column flex-md-row mt-4 gap-2 gap-md-4 gap-lg-5">
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={`${styles.thumbnail} align-self-center align-self-sm-center`} />
<div className="d-flex flex-column justify-content-center gap-2">
<div className="d-flex flex-column flex-wrap gap-1 justify-content-center justify-content-md-start mt-3 mt-md-0">
<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`}>
<img
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
alt={workshop.title}
className={`${styles.thumbnail} w-100`}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
</div>
<div 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">
{isAdmin ? (
<span className={`${styles.statusWorkshop} text-center d-inline-block py-2 mb-0 ${workshop.status === "pending" ? "alert alert-primary" : workshop.status === "realized" ? "alert alert-success" : "alert alert-danger"}`} style={{ width: "fit-content" }}>
<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" }}>
<span className="fw-bold">{workshop.status === "pending" ? (<><LuClock3 className="mb-1" /> Agendado</>) : workshop.status === "realized" ? (<><LuCheck className="mb-1" /> Realizado</>) : (<><LuX className="mb-1" /> Cancelado</>)}</span>
</span>
) : null}
<div className="d-flex flex-wrap text-start gap-1 mt-2">
<span className={`${styles.dateWorkshop} text-start d-inline-block`}>
<LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} />{workshop.date.split("-").reverse().join("-")}
</span>
<span className={`${styles.timeWorkshop} text-start d-inline-block`}>
<LuClock3 className={`${styles.iconClock} mb-1 me-2`} />{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
</span>
</div>
<h2 className={`${styles.title} text-start d-inline-block`}>{workshop.title}</h2>
<p className={`${styles.description} text-start`}>{workshop.description}</p>
</div>
{isAdmin && workshop.users.length === 0 ? (
<div className="">
<div className="text-decoration-none text-center d-flex flex-column gap-2 align-items-center">
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}><PiWarningCircleLight className={`${styles.iconWarning} mb-1 me-2`} />Não utilizadores inscritos neste workshop</span>
<div className="col-12 px-0">
<div className="d-flex flex-wrap gap-3 text-start gap-1 mt-2">
<div className={`${styles.dateWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
<small className="d-block text-muted me-2"><LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} /> Dia</small>
{workshop.date.split("-").reverse().join("-")}
</div>
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
<small className="d-block text-muted me-2"><LuClock3 className={`${styles.iconClock} mb-1 me-2`} /> Hora</small>
{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
</div>
{isAdmin && workshop.users.length === 0 ? (
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
<small className="d-block text-muted me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}>0 utilizadores</span>
</div>
</div>
) : isAdmin && workshop.users.length > 0 ? (
<div className="d-flex text-start flex-wrap gap-1 mt-2">
<span className={`${styles.contagemUtilizadoresInscritos} fs-6`}><LuUsers className="mb-1 me-2" /> {workshop.users.length === 1 ? "1 utilizador inscrito" : `${workshop.users.length} utilizadores inscritos`}</span>
<a type="button" className="align-content-center text-muted text-decoration-none fw-semibold fs-6" onClick={() => { setListagemInscritos(true); setFormEdit(false) }}>
(<LuClipboardList className="mb-1 me-2" /> Ver utilizadores)
</a>
</div>
) : null}
) : isAdmin && workshop.users.length > 0 ? (
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
<small className="d-block text-muted mb-1 me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
<span className={`${styles.contagemUtilizadoresInscritos} fs-6 px-0`}>{workshop.users.length === 1 ? "1 utilizador" : `${workshop.users.length} utilizadores`} </span>
<a type="button" className="align-content-center text-muted text-decoration-none fw-semibold fs-6 ms-1" onClick={() => { setListagemInscritos(true); setFormEdit(false) }}>
<small className="text-muted"> (Ver todos)</small>
</a>
</div>
) : null}
</div>
</div>
</div>
</div>
<div className="d-flex flex-column gap-2 justify-content-center mt-3">
<p className={`${styles.description} text-start mb-3`}>{workshop.description}</p>
</div>
{!formEdit && isAdmin ? (
<div className={`${styles.buttonsContainer} mt-3 d-flex flex-wrap gap-2 justify-content-center`}>
<button
type="button"
className={`${styles.updateButton} bg-primary`}
onClick={() => {
setFormEdit(true);
setListagemInscritos(false);
setTitle(workshop.title);
setDescription(workshop.description);
setImage(null);
setDate(new Date(workshop.date));
setTimeStart(new Date(`1970-01-01T${workshop.time_start}`));
setTimeEnd(new Date(`1970-01-01T${workshop.time_end}`));
}}
>
<LuPencil className="mb-1" /> Editar
</button>
{workshop.status === "pending" ? (
<button type="button" className={`${styles.cancelButton}`} onClick={handleCancelWorkshop}><LuTrash2 className="mb-1 btn-danger" /> Cancelar Workshop</button>
) : null}
{listagemInscritos ? (
<div 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">
<thead className="table-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Nome</th>
<th scope="col">Email</th>
<th scope="col">Inscrito em</th>
</tr>
</thead>
<tbody>
{workshop.users.map((user) => (
<tr key={user.id}>
<td className="text-muted fw-semibold">#{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
<td>
{user.pivot?.created_at
? new Date(user.pivot.created_at).toLocaleString("pt-PT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<div>
<div className={`${styles.users} mt-4`}>
{listagemInscritos ? (
<div className="table-responsive">
<button type="button" className={`${styles.btnClose} d-flex float-end`} onClick={() => setListagemInscritos(false)}><LuX className={`${styles.iconClose} mb-1`} /> </button>
<table className="table table-striped table-hover align-middle mt-3">
<thead className="table-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Nome</th>
<th scope="col">Email</th>
<th scope="col">Inscrito em</th>
</tr>
</thead>
<tbody>
{workshop.users.map((user) => (
<tr key={user.id}>
<td className="text-muted fw-semibold">#{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
<td>
{user.pivot?.created_at
? new Date(user.pivot.created_at).toLocaleString("pt-PT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</div>
</div>
{!formEdit && isAdmin ? (
<div className={`${styles.buttonsContainer} d-flex flex-wrap gap-2 justify-content-center mt-5`}>
<button
type="button"
className={`${styles.updateButton} bg-primary`}
onClick={() => {
setFormEdit(true);
setListagemInscritos(false);
setTitle(workshop.title);
setDescription(workshop.description);
setImage(null);
setDate(new Date(workshop.date));
setTimeStart(new Date(`1970-01-01T${workshop.time_start}`));
setTimeEnd(new Date(`1970-01-01T${workshop.time_end}`));
}}
>
<LuPencil className="mb-1" /> Editar
</button>
{workshop.status === "pending" ? (
<button type="button" className={`${styles.cancelButton}`} onClick={handleCancelWorkshop}><LuTrash2 className="mb-1 btn-danger" /> Cancelar Workshop</button>
) : null}
</div>
) : null}
{formEdit ? (
<form className="mt-3" onSubmit={update}>
<form 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">
<label className="form-label fw-bold" htmlFor="title">Título</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="title">Título</label>
<input type="text" className="form-control py-2 text-truncate" id="title" name="title" placeholder="Insira o título do workshop" defaultValue={workshop.title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="col-12 px-1 text-start">
<label className="form-label fw-bold" htmlFor="description">Descrição</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="description">Descrição</label>
<textarea rows={5} className="form-control py-2" id="description" name="description" placeholder="Insira uma descrição para o workshop" defaultValue={workshop.description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="col-12 px-1 text-start">
<label className="form-label fw-bold" htmlFor="image">Adicionar imagem</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="image">Adicionar imagem</label>
<div className="position-relative">
<span className={`${styles.inputFiles} form-control py-2 position-absolute top-0 start-0 px-3 w-100 text-decoration-none text-start fw-light fs-6 text-truncate`}>
<LuUpload className="mb-1 me-2" /> {image ? image.name : "Carregar nova imagem"}
@@ -337,7 +347,7 @@ export default function Workshop() {
</div>
</div>
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
<label className="form-label fw-bold mb-2" htmlFor="date">Data</label>
<label className={`${styles.label} form-label fw-bold mb-2`} htmlFor="date">Data</label>
<DatePicker
selected={date ?? new Date(workshop.date)}
onChange={(d: Date | null) => setDate(d)}
@@ -351,7 +361,7 @@ export default function Workshop() {
/>
</div>
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
<label className="form-label fw-bold" htmlFor="time_start">Hora de início</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_start">Hora de início</label>
<DatePicker
selected={time_start ? time_start : new Date(`1970-01-01T${workshop.time_start}`)}
onChange={(t: Date | null) => setTimeStart(t)}
@@ -370,7 +380,7 @@ export default function Workshop() {
/>
</div>
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
<label className="form-label fw-bold" htmlFor="time_end">Hora de término</label>
<label className={`${styles.label} form-label fw-bold`} htmlFor="time_end">Hora de término</label>
<DatePicker
selected={time_end ? time_end : new Date(`1970-01-01T${workshop.time_end}`)}
onChange={(t: Date | null) => setTimeEnd(t)}
@@ -394,8 +404,8 @@ export default function Workshop() {
</div>
<div className="col-12 col-sm-4 px-1 text-start d-flex flex-column">
<label className="form-label fw-bold" htmlFor="tags">Estado</label>
<div className="d-flex gap-4">
<label className={`${styles.label} form-label fw-bold`} htmlFor="tags">Estado</label>
<div className="d-flex flex-column flex-sm-row gap-2 gap-sm-4">
<span>
<input
type="radio"
@@ -441,8 +451,8 @@ export default function Workshop() {
) : null}
<div className="buttonsContainer mt-4 d-flex gap-2 justify-content-center">
<button type="button" className={`${styles.cancelButton}`} onClick={() => setFormEdit(false)}> Cancelar <LuX className="mb-1 text-white" /></button>
<button type="submit" className={`${styles.updateButton} bg-primary`} >Submeter dados <LuCheck className="mb-1 text-white" /></button>
<button type="button" className={`${styles.cancelButton}`} onClick={() => setFormEdit(false)}> <LuX className="mb-1" /> Cancelar </button>
<button type="submit" className={`${styles.updateButton} bg-primary`} ><LuCheck className="mb-1" /> Submeter dados</button>
</div>
</div>
</form>

View File

@@ -6,6 +6,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{
@@ -22,21 +23,31 @@
font-size: var(--size-font-subtitle);
}
.thumbnailWrapper{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 250px;
border-radius: var(--border-radius);
position: relative;
overflow: hidden;
}
.thumbnail{
width: 300px;
height: 250px;
object-fit: cover;
border-radius: var(--border-radius);
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-primary-color-opacity);
background-color: var(--bg-white);
color: var(--text-black);
font-size: 18px;
font-weight: 700;
border-radius: var(--border-radius-button);
box-shadow: var(--box-shadow);
padding: 5px 8px;
width: 180px;
min-width: 200px;
}
.contagemUtilizadoresInscritos{
@@ -67,6 +78,19 @@
color: var(--neutral-color);
}
.formEdit{
background-color: var(--bg-white);
border-radius: var(--border-radius);
padding: 20px;
box-shadow: var(--box-shadow);
}
.label {
color: var(--text-primary-color);
font-size: var(--size-font-small);
font-weight: 500;
}
.updateButton{
background-color: var(--text-primary);
color: var(--text-white);
@@ -74,7 +98,7 @@
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
min-width: 205px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
@@ -104,19 +128,19 @@
}
.deleteButton, .cancelButton{
background-color: var(--primary-color);
color: var(--text-white);
border: none;
background-color: transparent;
color: var(--text-primary-color);
border: 1px solid var(--text-primary-color);
border-radius: var(--border-radius-button);
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
width: fit-content;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
background-color: var(--bg-primary-color-opacity) !important;
border-color: var(--bg-primary-color-opacity) !important;
color: var(--text-primary-color) !important;
.icon{
color: var(--text-primary-color) !important;
@@ -176,6 +200,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);

View File

@@ -24,6 +24,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{

View File

@@ -5,11 +5,15 @@ import { LuArrowUpRight, LuCalendar, LuClock3, LuPencil } from "react-icons/lu";
import { Link } from "react-router";
import { CgSpinner } from "react-icons/cg";
import { ProgressBar } from "react-bootstrap";
import { usePreloadImages } from "../../../hooks/usePreloadImages";
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser"
import { useGetWorkshops } from "../../../hooks/useGetWorkshops";
import { useGetVideos } from "../../../hooks/useGetVideos";
import { useGetVideosLength } from "../../../hooks/useGetVideosLength";
import { useNextVideos } from "../../../hooks/useNextVideos";
import { useNextWorkshops } from "../../../hooks/useNextWorkshops";
import { useGetWorkshopsLength } from "../../../hooks/useGetWorkshopsLength";
import Swal from "sweetalert2";
import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
export default function Home() {
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
@@ -18,58 +22,65 @@ export default function Home() {
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [messageSuccess, setMessageSuccess] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const { preloadImages } = usePreloadImages();
const { getCurrentUser } = useGetCurrentUser();
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
const { getWorkshops } = useGetWorkshops();
const [workshops, setWorkshops] = useState<Workshop[]>([]);
const { getVideos } = useGetVideos();
const [videos, setVideos] = useState<Video[]>([]);
const { getVideosLength } = useGetVideosLength();
const { getNextVideos } = useNextVideos();
const [nextVideos, setNextVideos] = useState<Video[]>([]);
const { getNextWorkshops } = useNextWorkshops();
const [nextWorkshops, setNextWorkshops] = useState<Workshop[]>([]);
const { getWorkshopsLength } = useGetWorkshopsLength();
const [videosStats, setVideosStats] = useState({
videos: 0,
videosWatched: 0
});
const [workshopsStats, setWorkshopsStats] = useState({
workshops: 0,
workshopsInscribed: 0
});
useEffect(() => {
const fetchAll = async () => {
setLoading(true);
const [workshopsData, videosData, currentUserData] = await Promise.all([
getWorkshops(),
getVideos(),
getCurrentUser(),
]);
try {
const [
currentUserData,
videosLengthData,
workshopsLengthData,
nextVideosData,
nextWorkshopsData
] = await Promise.all([
getCurrentUser(),
getVideosLength(),
getWorkshopsLength(),
getNextVideos(),
getNextWorkshops(),
]);
setWorkshops(workshopsData as Workshop[]);
setVideos(videosData as Video[]);
setCurrentUserData(currentUserData.data);
setCurrentUserData((currentUserData as { data: User }).data);
setVideosStats(videosLengthData as { videos: number, videosWatched: number });
setWorkshopsStats(workshopsLengthData as { workshops: number, workshopsInscribed: number });
await preloadImages([
...(workshopsData as Workshop[]).map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
...(videosData as Video[]).map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
]);
if ("videos" in nextVideosData) {
setNextVideos(nextVideosData.videos);
}
setLoading(false);
if ("workshops" in nextWorkshopsData) {
setNextWorkshops(nextWorkshopsData.workshops as unknown as Workshop[]);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
fetchAll();
}, []);
const today = new Date();
const nextWorkshops = [...workshops] // ... cria uma cópia do array para não alterar o original
.filter((w: Workshop) =>
new Date(w.date + "T" + w.time_start) >= today &&
w.status === "pending"
) // Percorre cada workshop e só mantém os que têm data maior ou igual a hoje e status "pending"
.sort((a: Workshop, b: Workshop) => {
return (new Date(a.date + "T" + a.time_start).getTime() -
new Date(b.date + "T" + b.time_start).getTime()
);
})
.slice(0, 3); // pega nos próximos 3
/* Vídeos */
const now = 0;
const nextVideos = videos.slice(0, 3);
let progressoVideos = Math.round(videosStats.videosWatched / videosStats.videos * 100);
/* Inscrever num workshop */
async function inscrever(workshopId: number) {
@@ -92,8 +103,10 @@ export default function Home() {
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
const result = await getNextWorkshops();
if ("workshops" in result) {
setNextWorkshops(result.workshops as unknown as Workshop[]);
}
} else {
Swal.fire({
title: data.message,
@@ -124,8 +137,10 @@ export default function Home() {
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
const result = await getNextWorkshops();
if ("workshops" in result) {
setNextWorkshops(result.workshops as unknown as Workshop[]);
}
} else {
Swal.fire({
title: data.message,
@@ -136,8 +151,6 @@ export default function Home() {
}
}
console.log(workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === currentUserData?.id)));
/* Formulário de contacto */
async function sendMail(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -193,50 +206,74 @@ export default function Home() {
}
return (
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
<div className={`${styles.container} mx-2 mx-sm-0 p-2 p-sm-4 p-lg-0`}>
<div className=" ps-0">
<div className={`${styles.containerVideos} px-2 p-sm-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 px-0 `}>Continuar Formação</h2>
<h2 className={`${styles.subtitle} text-center text-sm-start mt-4 mt-sm-3 mb-4 px-0 `}>{isAdmin ? "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>
</div>
<ProgressBar className={`${styles.progressBar} px-1`} now={now} label={`${now}%`} />
{!isAdmin && (
<>
<ProgressBar className={`${styles.progressBar}`} now={progressoVideos} label={`${progressoVideos}%`} />
<div className="row mt-4">
{nextVideos.length > 0 ? nextVideos.map((video) => (
{progressoVideos === 100 && (
<div className="text-center mt-3">
<span className="text-black fw-bold fs-6">Parabéns! A sua formação está completa!</span>
</div>
)}
</>
)}
<div className="row mt-4 px-2">
{nextVideos.length > 0 ? nextVideos.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}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<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
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
className={`${styles.videoThumbnail} position-absolute top-0 start-0 bottom-0 h-100`}
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} d-flex text-wrap mb-1`}>{video.title}</h2>
{/* <span className={`${styles.descriptionVideo} mt-0 pe-3 text-truncate`}>{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>
)) : <div className="col-12 text-start mt-4 px-0">
<span className={` text-muted fs-5`}>Nenhum vídeo encontrado</span>
</div>}
)) : nextVideos.length === 0 ? (
<div className="col-12 text-start ps-1">
<span className="text-muted fs-5">Nenhum vídeo encontrado</span>
</div>
) : null}
</div>
</div>
<div className="ms-0 ps-0 mt-4">
<div className="ms-0 px-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>
</div>
<div className="row mt-4 mt-sm-1">
{nextWorkshops.length > 0 ? nextWorkshops.filter((workshop: Workshop) => workshop.status === "pending").slice(0, 3).map((workshop) => (
<div className="row mt-4 mt-sm-1 px-2">
{nextWorkshops.length > 0 ? nextWorkshops.map((workshop: Workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<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>
@@ -244,7 +281,6 @@ export default function Home() {
</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>
@@ -254,12 +290,12 @@ export default function Home() {
<Link to={`${isAdmin ? `/admin/edit-workshop/${workshop.id}` : `/workshop/${workshop.id}`}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}> Detalhes </Link>
{!isAdmin ? (
currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
currentUserData && (workshop.users as unknown as number[]).includes(currentUserData.id) ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => cancelarInscricao(workshop.id)} key={workshop.id} style={{ width: '180px' }}>
Anular inscrição
</button>
) : (
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => inscrever(workshop.id)} key={workshop.id} style={{ width: '180px' }}>
Inscrever-me
</button>
)
@@ -328,39 +364,21 @@ export default function Home() {
</form>
</div>
{!isAdmin ? (
<div className="col-12 col-lg-4 mt-lg-5 px-0 ps-lg-4">
<div className="h-100 pt-4">
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-3 text-white" >Vídeos assistidos</span>
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>0/{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
</div>
<div className="col-12 col-lg-4 mt-lg-5 px-0 ps-lg-4">
<div className="h-100 pt-4">
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-3 text-white" > {isAdmin ? "Vídeos ativos" : "Vídeos assistidos"}</span>
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{isAdmin ? videosStats.videos : `${videosStats.videosWatched}/${videosStats.videos}`}</span>
</div>
<div className="d-flex flex-column">
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>Workshops Inscrito</span>
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{workshops.filter((workshop: Workshop) => workshop.status === "pending" && workshop.users.some((user: User) => user.id === currentUserData?.id)).length}</span>
</div>
<div className="d-flex flex-column">
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>{isAdmin ? "Workshops agendados" : "Workshops inscrito"}</span>
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{isAdmin ? workshopsStats.workshops : `${workshopsStats.workshopsInscribed}/${workshopsStats.workshops}`}</span>
</div>
</div>
</div>
) : isAdmin ? (
<div className="col-12 col-lg-4 mt-lg-5 px-0 ps-lg-4">
<div className="h-100 pt-4">
<div className={`${styles.userVideosWatched} text-start justify-content-evenly d-flex flex-column h-100 p-4`}>
<div className="d-flex flex-column">
<span className="fw-normal fs-3 text-white" >Vídeos ativos</span>
<span className=" fw-bold text-white" style={{ fontSize: "4rem" }}>{JSON.parse(localStorage.getItem("videos") || "[]").filter((video: Video) => video.is_active === true).length}</span>
</div>
<div className="d-flex flex-column">
<span className="fw-normal fs-3" style={{ color: "var(--bg-grey)" }}>Workshops Agendados</span>
<span className="fw-bold text-white" style={{ fontSize: "4rem" }}>{workshops.filter((workshop: Workshop) => workshop.status === "pending").length}</span>
</div>
</div>
</div>
</div>
) : null}
</div>
</div>
</div>

View File

@@ -22,11 +22,17 @@
font-weight: 600;
color: var(--text-primary-color);
transition: all .3s ease;
&:hover {
color: var(--primary-color-contrast);
}
}
.containerVideos {
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.dateWorkshop,
.timeWorkshop {
background-color: var(--bg-white);
@@ -65,49 +71,52 @@
height: 150px;
}
.linkWorkshop{
.linkWorkshop {
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-grey);
color: var(--text-black);
transition: all .3s ease;
&:hover{
&:hover {
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
}
}
.btncancelarInscricao{
.btncancelarInscricao {
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--bg-primary-color-opacity);
color: var(--text-primary-color);
transition: all .3s ease;
&:hover{
&:hover {
background-color: var(--bg-primary-color);
color: var(--text-white);
}
}
.btnInscrever{
.btnInscrever {
display: block;
width: fit-content;
border-radius: var(--border-radius);
transition: all 0.3s ease;
font-weight: 600;
background-color: var(--tertiary-color);
color: var(--text-white);
transition: all .3s ease;
&:hover{
&:hover {
background-color: var(--bg-tertiary-color-opacity);
color: var(--text-tertiary-color);
}
@@ -122,10 +131,10 @@
overflow: visible;
color: var(--text-primary-color);
font-weight: 800;
background-color: var(--bg-primary-color);
background-color: var(--bg-primary-color-opacity);
}
.iconEdit{
.iconEdit {
color: var(--text-white);
background-color: var(--bg-primary-color);
font-size: 2.3rem;
@@ -138,7 +147,8 @@
border-radius: var(--border-radius);
padding: 10px;
transition: all 0.3s ease;
&:hover{
&:hover {
background-color: var(--neutral-color);
}
}
@@ -152,14 +162,32 @@
}
.boxVideo {
background: #e0e0e0; /* cinzento enquanto a imagem não carrega */
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.thumbnailWorkshop {
background: #e0e0e0; /* cinzento enquanto a imagem não carrega */
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
}
.boxVideo::after {
content: '';
content: '\F4F4';
color: var(--bg-grey);
font-size: 4rem;
align-content: center;
font-family: 'bootstrap-icons';
position: absolute;
top: 0;
left: 0;
@@ -167,9 +195,14 @@
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
transition: all 0.3s ease;
}
.boxVideoInfo{
.boxVideo:hover::after {
color: var(--bg-primary-color);
}
.boxVideoInfo {
z-index: 1000;
position: relative;
max-width: 100%;
@@ -196,38 +229,39 @@
transition: all 0.3s ease;
}
.formContact{
.formContact {
background-color: var(--bg-white);
border-radius: var(--border-radius);
}
.label{
.label {
color: var(--text-primary-color);
font-size: var(--size-font-small);
font-weight: 500;
}
.submitButton{
.submitButton {
background-color: var(--primary-color);
color: var(--text-white);
border-radius: var(--border-radius-button);
border: none;
padding: 10px 20px;
font-size: var(--size-font-small);
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover{
&:hover {
background-color: var(--bg-primary-color-opacity);
color: var(--primary-color) !important;
}
}
.userVideosWatched{
.userVideosWatched {
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
background-color: var(--bg-neutral-color);
}
@@ -235,6 +269,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin {
from {
transform: rotate(0deg);

View File

@@ -48,7 +48,7 @@
.userVideosWatched{
border-radius: var(--border-radius);
background-color: var(--bg-primary-color);
background-color: var(--bg-neutral-color);
}
.closeFormButton{

View File

@@ -4,14 +4,18 @@ import { Link, useSearchParams } from "react-router";
import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
import { LuCalendar, LuClock3, LuPencil } from "react-icons/lu";
import { usePreloadImages } from "../../../hooks/usePreloadImages";
import { useGetVideosSearch } from "../../../hooks/useGetVideosSearch";
import { useGetWorkshopsSearch } from "../../../hooks/useGetWorkshopsSearch";
import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
export default function Search() {
const [videos, setVideos] = useState<Video[]>([]);
const [workshops, setWorkshops] = useState<Workshop[]>([]);
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [loading, setLoading] = useState(true);
const { preloadImages } = usePreloadImages();
const { getVideosSearch } = useGetVideosSearch();
const { getWorkshopsSearch } = useGetWorkshopsSearch();
const [searchParams] = useSearchParams();
const query = (searchParams.get("q") ?? "").trim();
@@ -22,73 +26,36 @@ export default function Search() {
useEffect(() => {
const fetchAll = async () => {
setLoading(true);
const [videoData, workshopData] = await getSearch();
await preloadImages([
...videoData.map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
...workshopData.map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
]);
const [videoResponse, workshopResponse] = await Promise.all([
getVideosSearch(query),
getWorkshopsSearch(query),
]);
let videoData: Video[] = [];
let workshopData: Workshop[] = [];
if("videos" in videoResponse) {
videoData = videoResponse.videos;
setVideos(videoData);
} else {
setVideos([]);
}
if("workshops" in workshopResponse) {
workshopData = workshopResponse.workshops;
setWorkshops(workshopData);
} else {
setWorkshops([]);
}
setLoading(false);
};
fetchAll();
}, [query]);
async function getSearch(): Promise<[Video[], Workshop[]]> {
if (query === "") {
setVideos([]);
setWorkshops([]);
setLoading(false);
setError({ message: "A pesquisa não pode ser vazia",
data: null,
errors: {} });
return [[], []];
}
const [videoResponse, workshopResponse] = await Promise.all([
fetch(`http://127.0.0.1:8000/api/videos?search=${encodeURIComponent(query)}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
}),
fetch(`http://127.0.0.1:8000/api/workshops?search=${encodeURIComponent(query)}`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`
},
}),
]);
let videoData: Video[] = [];
let workshopData: Workshop[] = [];
if(videoResponse.ok) {
const data = await videoResponse.json();
videoData = data.data as Video[];
setVideos(videoData);
} else {
setVideos([]);
}
if(workshopResponse.ok) {
const data = await workshopResponse.json();
workshopData = data.data as Workshop[];
setWorkshops(workshopData);
} else {
setWorkshops([]);
}
return [videoData, workshopData];
}
if(loading) {
return(
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
@@ -98,6 +65,14 @@ export default function Search() {
);
}
if(error) {
return(
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span className="text-muted">Ocorreu um erro ao carregar os resultados da sua pesquisa.</span>
</div>
);
}
return (
<div className={styles.container}>
<h1 className={`${styles.subtitle} mb-4`}>Resultados da pesquisa: "{query}"</h1>
@@ -107,17 +82,23 @@ export default function Search() {
<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>
{videos.map((video) => (
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2">
<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}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<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>
<h2 className={`${styles.titleVideo} d-flex text-wrap 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>
@@ -130,10 +111,16 @@ export default function Search() {
<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>
{workshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 col-xl-3 p-2 position-relative" key={workshop.id}>
<div 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="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>
@@ -141,7 +128,6 @@ export default function Search() {
</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>

View File

@@ -1,3 +1,6 @@
/* No teu CSS */
@import "bootstrap-icons/font/bootstrap-icons.css";
.container{
width: 100%;
max-width: 1400px;
@@ -42,6 +45,9 @@
}
.boxVideo {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
@@ -49,7 +55,11 @@
}
.boxVideo::after {
content: '';
content: '\F4F4';
color: var(--bg-grey);
font-size: 4rem;
align-content: center;
font-family: 'bootstrap-icons';
position: absolute;
top: 0;
left: 0;
@@ -57,6 +67,11 @@
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
transition: all 0.3s ease;
}
.boxVideo:hover::after {
color: var(--bg-primary-color);
}
.boxVideoInfo{
@@ -131,12 +146,17 @@
}
.thumbnailWorkshop{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
height: 150px;
position: relative;
overflow: hidden;
}
.icon{
@@ -147,6 +167,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);

View File

@@ -5,6 +5,7 @@ import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
import { LuPlus } from "react-icons/lu";
import { LuPencil } from "react-icons/lu";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
export default function Videos() {
const [loadingTimeout, setLoadingTimeout] = useState(false);
@@ -139,7 +140,13 @@ export default function Videos() {
<div className={`${styles.boxVideo}`} data-category={video.categories?.map((category) => category.id).join(', ')}>
<Link to={`/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
<a className={`${styles.linkVideo} text-decoration-none pb-2`} href={`/video/${video.id}`} key={video.id} data-category={video.categories?.map((category) => category.name).join(', ')}>
<img className={`${styles.thumbnail}`} src={`http://127.0.0.1:8000/storage/${video.thumbnail}`} alt={video.title} />
<img
className={`${styles.thumbnail}`}
src={`http://127.0.0.1:8000/storage/${video.thumbnail}`}
alt={video.title}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
<div className="px-3 text-start text-truncate">
<h2 className={`${styles.titleVideo} mt-2`}>{video.title}</h2>
<span className="text-muted text-black mt-0

View File

@@ -52,7 +52,13 @@
}
.boxVideo{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.titleVideo{
@@ -74,6 +80,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);

View File

@@ -4,6 +4,7 @@ import { data, Link } from "react-router";
import { CgSpinner } from "react-icons/cg";
import styles from "./styles.module.css";
import { LuPlus, LuCalendar, LuClock3 } from "react-icons/lu";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../../utils/imageSkeleton";
export default function Workshops() {
const [workshops, setWorkshops] = useState<Workshop[]>([]);
@@ -87,8 +88,14 @@ export default function Workshops() {
{filteredWorkshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<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>

View File

@@ -87,12 +87,17 @@
}
.thumbnailWorkshop{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
height: 150px;
position: relative;
overflow: hidden;
}
.icon{
@@ -103,6 +108,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);

View File

@@ -2,10 +2,11 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import type { ApiErrorResponse, Video } from "../../../types";
import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
import { LuArrowLeft } from "react-icons/lu";
import { Plyr } from "plyr-react";
import "plyr-react/plyr.css";
import { useVideoWatch } from "../../../hooks/useVideoWatch";
import { PiCheckCircleFill } from "react-icons/pi";
export default function Video() {
const { id } = useParams();
@@ -13,25 +14,72 @@ export default function Video() {
const [error, setError] = useState<ApiErrorResponse | null>(null);
const [loading, setLoading] = useState(true);
const [playerReady, setPlayerReady] = useState(false);
const { watched, markAsWatched } = useVideoWatch(Number(id), video?.watched ?? false);
useEffect(() => {
getVideo();
}, [id]);
useEffect(() => {
if (!video) return;
if (!video) {
setPlayerReady(false);
return;
}
const timer = setTimeout(() => {
setPlayerReady(false);
const interval = setInterval(() => {
const videoElement = document.querySelector("video");
if (!videoElement) return;
if (videoElement.src.includes("blank.mp4")) return;
clearInterval(interval);
setPlayerReady(true);
}, 1000); // espera 1 segundo após o video carregar
}, 300);
return () => clearTimeout(timer);
return () => clearInterval(interval);
}, [video]);
useEffect(() => {
if (!playerReady) return;
let videoElement: HTMLVideoElement | null = null;
const handleEnded = () => {
markAsWatched();
};
const interval = setInterval(() => {
const el = document.querySelector("video");
if (!el) return;
if (el.src.includes("blank.mp4")) return;
clearInterval(interval);
videoElement = el;
videoElement.addEventListener("ended", handleEnded);
}, 300);
return () => {
clearInterval(interval);
if (videoElement) {
videoElement.removeEventListener("ended", handleEnded);
}
};
}, [playerReady, markAsWatched]);
useEffect(() => {
if (video?.watched) {
markAsWatched();
}
}, [video, markAsWatched]);
async function getVideo() {
setLoading(true);
setPlayerReady(false);
try {
const response = await fetch(`http://127.0.0.1:8000/api/edit-video/${id}`, {
const response = await fetch(`http://127.0.0.1:8000/api/video/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
@@ -44,43 +92,62 @@ export default function Video() {
if (response.ok) {
setVideo(data.data);
setError(null);
} else {
setVideo(null);
setError(data as ApiErrorResponse);
}
} catch {
console.error(error);
setVideo(null);
setError({ message: "Erro de ligação" } as ApiErrorResponse);
} finally {
setLoading(false);
}
}
if(loading) {
return (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar vídeo...</span>
const playerSkeleton = (
<div className={`${styles.playerContent} ${styles.videoPlayerSkeleton}`} aria-hidden="true" />
);
const pageSkeleton = (
<div className="my-3 d-flex flex-column gap-2">
<div className={`${styles.titleSkeleton} mb-3`} aria-hidden="true" style={{ maxWidth: "1300px", margin: "0 auto" }} />
<div style={{ maxWidth: "1300px", margin: "0 auto", width: "100%" }}>
<div className={styles.playerWrapper}>
{playerSkeleton}
</div>
<div className={styles.descriptionSkeleton} aria-hidden="true" />
<div className={styles.descriptionSkeletonShort} aria-hidden="true" />
</div>
)
}
</div>
);
return (
<>
{!playerReady ? (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
<span>A carregar vídeo...</span>
<div className={`${styles.container} p-3 p-xl-0`}>
<div 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>
) : (
<div className={`${styles.container} p-3 p-xl-0`}>
<div 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>
{video ? (
<div className="my-3 d-flex flex-column gap-2">
<span className={`${styles.title}`}>{video.title}</span>
<div style={{ maxWidth: "1000px", margin: "0 auto", display: playerReady ? 'block' : 'none' }}>
{loading ? (
pageSkeleton
) : video ? (
<div className="my-3 d-flex flex-column gap-2">
<span className={`${styles.title} mb-3`}>{video.title}</span>
<div style={{ maxWidth: "1300px", margin: "0 auto", width: "100%" }}>
{watched && (
<span className="d-block badge text-success text-start fs-6 px-0">
<PiCheckCircleFill className="mb-1 me-1" /> Visto
</span>
)}
<div className={styles.playerWrapper}>
{!playerReady && playerSkeleton}
<div className={`${styles.playerContent} ${playerReady ? "" : styles.playerHidden}`}>
<Plyr
source={{
type: "video",
@@ -88,19 +155,15 @@ export default function Video() {
}}
/>
</div>
<small className="text-start"><b>Publicado a: </b>{video.created_at}</small>
<p className="text-start mt-2">{video.description}</p>
</div>
) : (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span className="alert alert-danger">{error?.message}</span>
</div>
)}
<p className="text-start mt-3 fs-6">{video.description}</p>
</div>
</div>
) : (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span className="alert alert-danger">{error?.message}</span>
</div>
)}
</>
)
</div>
);
}

View File

@@ -1,31 +1,140 @@
.container{
.container {
align-self: start;
max-width: 1400px !important;
width: 100% !important;
}
.title{
.title {
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
}
.LinkIcon, .linkText{
.LinkIcon,
.linkText {
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{
.button:hover .LinkIcon,
.button:hover .linkText {
color: var(--primary-color);
}
.animateSpin{
.animateSpin {
animation: spin 1s linear infinite;
}
@keyframes spin{
from{
@keyframes spin {
from {
transform: rotate(0deg);
}
to{
to {
transform: rotate(360deg);
}
}
.titleSkeleton {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 2rem;
width: min(100%, 480px);
border-radius: var(--border-radius);
}
.descriptionSkeleton {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 1rem;
width: 100%;
border-radius: var(--border-radius);
margin-top: 0.75rem;
}
.descriptionSkeletonShort {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 1rem;
width: 70%;
border-radius: var(--border-radius);
margin-top: 0.5rem;
}
/* Altura fixa 16:9 desde o primeiro paint — evita salto quando o Plyr monta */
.playerWrapper {
position: relative;
width: 100%;
height: 0;
padding-top: 56.25%;
flex-shrink: 0;
border-radius: var(--border-radius);
overflow: hidden;
}
.playerContent {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.videoPlayerSkeleton {
z-index: 2;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
border-radius: var(--border-radius);
}
.videoPlayerSkeleton::after {
content: '\F4F4';
color: var(--bg-grey);
font-size: 4rem;
align-content: center;
font-family: 'bootstrap-icons';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.playerHidden {
opacity: 0;
pointer-events: none;
}
.playerWrapper :global(.plyr),
.playerWrapper :global(.plyr__video-wrapper),
.playerWrapper :global(video) {
border-radius: var(--border-radius);
}
.playerWrapper :global(.plyr) {
width: 100%;
height: 100%;
overflow: hidden;
}
.playerWrapper :global(.plyr__video-wrapper),
.playerWrapper :global(video) {
width: 100%;
height: 100%;
object-fit: contain;
}
@keyframes skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -5,10 +5,11 @@ import styles from "./styles.module.css";
import { CgSpinner } from "react-icons/cg";
import { LuPlus, LuSettings2 } from "react-icons/lu";
import { LuPencil } from "react-icons/lu";
import { Dropdown, Form } from "react-bootstrap";
import { usePreloadImages } from "../../../hooks/usePreloadImages";
import { Dropdown, Form, Pagination } from "react-bootstrap";
import { useGetVideos } from "../../../hooks/useGetVideos";
import { useDebounce } from "../../../hooks/useDebounce";
import { PiCheckCircleFill } from "react-icons/pi";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
export default function Videos() {
const [loading, setLoading] = useState(true);
@@ -16,12 +17,19 @@ export default function Videos() {
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all");
const [isAdmin, setIsAdmin] = useState(false);
const { preloadImages } = usePreloadImages();
const { getVideos } = useGetVideos();
const [videos, setVideos] = useState<Video[]>([]);
const [search, setSearch] = useState<string>("");
const [searchCompleted, setSearchCompleted] = useState(false);
const debouncedSearch = useDebounce(search, 500);
const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [loadingVideos, setLoadingVideos] = useState(false);
const videosToShow = videos;
useEffect(() => {
getRole();
getCategories();
}, []);
async function getRole() {
const response = await fetch("http://127.0.0.1:8000/api/me", {
@@ -43,7 +51,7 @@ export default function Videos() {
}
}
async function getCategories() {
async function getCategories() {
const response = await fetch("http://127.0.0.1:8000/api/categories", {
method: "GET",
headers: {
@@ -52,68 +60,63 @@ export default function Videos() {
Authorization: `Bearer ${localStorage.getItem("token")}`
},
});
const data = await response.json();
if (response.ok) {
const data = await response.json();
setCategories(data.data as Category[]);
setError(null);
return data.data as Category[];
setCategories(data.data);
} else {
const data = await response.json();
setCategories([]);
setError(data as ApiErrorResponse);
}
return [];
}
useEffect(() => {
setSearchCompleted(false);
const fetchAll = async () => {
getRole();
const fetchVideos = async () => {
setLoadingVideos(true);
try {
const [videosData] = await Promise.all([
getVideos(debouncedSearch),
getCategories(),
]);
if (Array.isArray(videosData)) {
setVideos(videosData);
const videosData = await getVideos({
page: currentPage,
search: debouncedSearch,
category:
selectedCategoryId !== "all" &&
selectedCategoryId !== "active" &&
selectedCategoryId !== "inactive" &&
selectedCategoryId !== "watched" &&
selectedCategoryId !== "unwatched"
? selectedCategoryId
: undefined,
status:
selectedCategoryId === "active"
? "active"
: selectedCategoryId === "inactive"
? "inactive"
: undefined,
watched:
selectedCategoryId === "watched"
? 1
: selectedCategoryId === "unwatched"
? 0
: undefined,
});
if ("videos" in videosData) {
setVideos(videosData.videos);
setLastPage(videosData.meta.last_page);
setCurrentPage(videosData.meta.current_page);
} else {
setVideos([]);
}
await preloadImages([
...(videosData as Video[]).map((v: Video) => `http://127.0.0.1:8000/storage/${v.thumbnail}`),
]);
setVideos(videosData as Video[]);
setSearchCompleted(true);
setLoading(false);
} catch (e) {
} catch {
setVideos([]);
setError(e as ApiErrorResponse);
}
finally {
} finally {
setLoading(false);
setSearchCompleted(true);
setLoadingVideos(false);
}
};
fetchAll();
}, [debouncedSearch]);
const filteredVideos = videos.filter((video) => {
if (selectedCategoryId === "all") return true;
if (selectedCategoryId === "active") return Boolean(video.is_active);
if (selectedCategoryId === "inactive") return !video.is_active;
return (video.categories ?? []).some(
(category) => String(category.id) === selectedCategoryId
);
});
fetchVideos();
}, [debouncedSearch, currentPage, selectedCategoryId]);
if (loading) {
return (
@@ -125,16 +128,16 @@ export default function Videos() {
return (
<div className={`${styles.container} p-2 p-sm-4 p-lg-0`}>
<h1 className={`${styles.title} mt-1`}>Videos</h1>
<h1 className={`${styles.title} mt-1`}>Vídeos</h1>
{error && (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span className="text-muted fs-5">{error.message}</span>
</div>
)}
<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 ">
<div 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)}
@@ -143,15 +146,27 @@ export default function Videos() {
<div className="col-12 col-sm-5 col-md-4 col-lg-3 d-flex mt-2 mt-sm-4">
<Dropdown className="flex-grow-1" onSelect={(value) => {
setCurrentPage(1);
if (value) {
setSelectedCategoryId(value);
}
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100">
<LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' : selectedCategoryId === 'active' ? 'Ativos' : 'Inativos'}
<LuSettings2 /> {selectedCategoryId === 'all' ? 'Todos' :
selectedCategoryId === 'active' ? 'Ativos' :
selectedCategoryId === 'inactive' ? 'Inativos' :
selectedCategoryId === 'watched' ? 'Vistos' :
selectedCategoryId === 'unwatched' ? 'Não vistos' :
categories.find(c => String(c.id) === selectedCategoryId)?.name ?? 'Todos'}
</Dropdown.Toggle>
<Dropdown.Menu style={{ zIndex: 4000 }} className="text-center w-100">
<Dropdown.Item eventKey="all" active={selectedCategoryId === 'all'}>Todos</Dropdown.Item>
{!isAdmin && (
<>
<Dropdown.Item eventKey="watched" active={selectedCategoryId === 'watched'}>Vistos</Dropdown.Item>
<Dropdown.Item eventKey="unwatched" active={selectedCategoryId === 'unwatched'}>Não vistos</Dropdown.Item>
</>
)}
{isAdmin === true && (
<>
<Dropdown.Item eventKey="active" active={selectedCategoryId === 'active'}>Ativos</Dropdown.Item>
@@ -165,75 +180,91 @@ export default function Videos() {
</Dropdown>
</div>
<div 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>
</div>
{/* <div className="row py-3 g-4">
<div className="col-12 col-sm-6 text-start">
<label htmlFor="filter" className="form-label fw-bold text-start">Filtrar vídeos</label>
<div className="position-relative">
<select className="form-control select-filter" name="filter" id="filter" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value)}>
<option key="all" value="all">Todos</option>
{isAdmin === true && (
<>
<option key="active" value="active">Ativos</option>
<option key="inactive" value="inactive">Inativos</option>
</>
)}
{categories.map((category) => (
<option key={category.id} value={String(category.id)}>{category.name}</option>
))}
</select>
<span className="position-absolute top-50 end-0 translate-middle-y me-2"><LuChevronDown className="mb-1" /></span>
</div>
<span className="form-text text-muted"> Selecione um filtro para filtrar os vídeos</span>
{isAdmin && (
<div 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>
{isAdmin && (
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center">
<Link to="/admin/create-video" className={`${styles.btnAdicionarVideo} text-decoration-none`}><LuPlus className="mb-1" />Adicionar vídeo</Link>
</div>
)}
</div> */}
)}
</div>
<div className={`${styles.containerVideos} mt-3`}>
<div className="row g-3 p-0">
{videos.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum vídeo encontrado</span>
</div>
) : searchCompleted && videos.length > 0 ? (
<>
{filteredVideos.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`} key={video.id}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<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} d-flex text-wrap mb-1`}>{video.title}</h2>
{/* <span className={`${styles.descriptionVideo} mt-0 pe-3`}>{video.description}</span> */}
</div>
</div>
</Link>
</div>
))}
</>
) : (
{ loadingVideos ? (
<div className="col-12 text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
) : 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}>
<div className={`${styles.boxVideo} position-relative`} data-category={video.categories?.map((category) => category.id).join(', ')} >
{isAdmin && (
<Link to={`/admin/edit-video/${video.id}`}> <LuPencil className={`${styles.iconEdit} text-decoration-none`} /> </Link>
)}
<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} d-flex text-wrap 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>
))}
</div>
<div>
<div className="d-flex justify-content-center align-items-center gap-3 py-3">
<Pagination>
<Pagination.Prev
disabled={currentPage <= 1}
onClick={() => { setCurrentPage((p) => p - 1); setLoadingVideos(true); }}
/>
{currentPage > 3 && <Pagination.Item onClick={() => { setCurrentPage(1); setLoadingVideos(true); }}>1</Pagination.Item>}
{currentPage > 4 && <Pagination.Ellipsis disabled />}
{Array.from({ length: 5 }, (_, i) => currentPage - 2 + i)
.filter((p) => p >= 1 && p <= lastPage)
.map((p) => (
<Pagination.Item key={p} active={p === currentPage} onClick={() => { setCurrentPage(p); setLoadingVideos(true); }}>
{p}
</Pagination.Item>
))}
{currentPage < lastPage - 3 && <Pagination.Ellipsis disabled />}
{currentPage < lastPage - 2 && (
<Pagination.Item onClick={() => { setCurrentPage(lastPage); setLoadingVideos(true); }}>{lastPage}</Pagination.Item>
)}
<Pagination.Next
disabled={currentPage >= lastPage}
onClick={() => { setCurrentPage((p) => p + 1); setLoadingVideos(true); }}
/>
</Pagination>
</div>
</div>
</div>
) : (
<>
{videosToShow.length === 0 && (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum vídeo encontrado com o filtro selecionado</span>
</div>
)}
</>
)}
{ videos.length > 0 && filteredVideos.length === 0 && (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum vídeo encontrado com o filtro selecionado</span>
</div>
)}
</div>
</div>
</div>

View File

@@ -52,6 +52,9 @@
}
.boxVideo {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 200px;
position: relative;
overflow: hidden;
@@ -59,7 +62,11 @@
}
.boxVideo::after {
content: '';
content: '\F4F4';
color: var(--bg-grey);
font-size: 4rem;
align-content: center;
font-family: 'bootstrap-icons';
position: absolute;
top: 0;
left: 0;
@@ -67,6 +74,11 @@
height: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%);
z-index: 1;
transition: all 0.3s ease;
}
.boxVideo:hover::after {
color: var(--bg-primary-color);
}
.boxVideoInfo{
@@ -78,7 +90,7 @@
.titleVideo {
color: var(--text-white);
font-size: var(--size-font-text);
font-weight: 700;
font-weight: 900;
}
.descriptionVideo {
@@ -100,6 +112,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);

View File

@@ -2,12 +2,12 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import { CgSpinner } from "react-icons/cg";
import type { ApiErrorResponse, User, Workshop } from "../../../types";
import { LuArrowLeft, LuCalendar, LuClock3 } from "react-icons/lu";
import { LuArrowLeft, LuCalendar, LuClock3, LuUsers } from "react-icons/lu";
import "react-datepicker/dist/react-datepicker.css";
import Swal from "sweetalert2";
import styles from "./styles.module.css";
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser";
import { usePreloadImages } from "../../../hooks/usePreloadImages";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
export default function Workshop() {
const user = JSON.parse(localStorage.getItem("user") as unknown as string) as User;
@@ -20,7 +20,6 @@ export default function Workshop() {
const [error, setError] = useState<ApiErrorResponse | null>(null);
const { getCurrentUser } = useGetCurrentUser();
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
const { preloadImages } = usePreloadImages();
useEffect(() => {
const fetchAll = async () => {
@@ -31,15 +30,9 @@ export default function Workshop() {
getCurrentUser(),
]);
setWorkshop(workshopData as Workshop); // workshopData já é o Workshop
setWorkshop(workshopData as Workshop);
setCurrentUserData(currentUserData.data);
if (workshopData) {
await preloadImages([
`http://127.0.0.1:8000/storage/${(workshopData as Workshop).image}`
]);
}
setLoading(false);
};
@@ -60,7 +53,7 @@ export default function Workshop() {
const data = await response.json();
if (response.ok) {
return data.data as Workshop; // ← um único Workshop, não array
return data.data as Workshop;
} else {
setError(data as ApiErrorResponse);
return null;
@@ -159,48 +152,59 @@ export default function Workshop() {
<span className="text-muted fs-5">{error.message}</span>
</div>
)}
{ workshop && (
{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`}>
<img
src={`http://127.0.0.1:8000/storage/${workshop.image}`}
alt={workshop.title}
className={`${styles.thumbnail} w-100`}
style={imageSkeletonFadeStyle}
onLoad={onImageSkeletonLoad}
/>
</div>
<div className="col-12 col-lg-9 d-flex flex-column justify-content-between gap-2 mt-3 mt-lg-0">
<div className="d-flex flex-column flex-wrap gap-1 justify-content-center justify-content-md-start">
<h2 className={`${styles.title} text-start d-inline-block`}>{workshop.title}</h2>
<span className={styles.title}>{workshop.title}</span>
<p className={`${styles.description} text-start mb-3`}>{workshop.description}</p>
<div className="d-flex flex-column flex-md-row mt-4 gap-2 gap-md-4 gap-lg-5">
<img src={`http://127.0.0.1:8000/storage/${workshop.image}`} alt={workshop.title} className={`${styles.thumbnail} align-self-center align-self-sm-center`} />
<div className="d-flex flex-column justify-content-center gap-2">
<div className="d-flex flex-column flex-wrap
gap-3 justify-content-center justify-content-md-start mb-2">
<div className="d-flex flex-wrap flex-md-column text-start gap-1 mt-2">
<span className={`${styles.dateWorkshop} text-start d-inline-block`}>
<LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} />{workshop.date.split("-").reverse().join("-")}
</span>
<span className={`${styles.timeWorkshop} text-start d-inline-block`}>
<LuClock3 className={`${styles.iconClock} 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 className="d-flex flex-wrap gap-1">
<div className={`${styles.dateWorkshop} text-start d-inline-block px-3 py-2`}>
<small className="d-block text-muted mb-1 me-2"><LuCalendar className={`${styles.iconCalendar} mb-1 me-2`} /> Dia</small>
{workshop.date.split("-").reverse().join("-")}
</div>
<div className="mx-auto ms-sm-0">
{!isAdmin ? (
currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
Anular inscrição
</button>
) : (
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
Inscrever-me
</button>
)
) : null}
<div className={`${styles.timeWorkshop} text-start d-inline-block px-3 py-2`}>
<small className="d-block text-muted mb-1 me-2"><LuClock3 className={`${styles.iconClock} mb-1 me-2`} /> Hora</small>
{workshop.time_start.slice(0, 5).split(":").join("h")} - {workshop.time_end.slice(0, 5).split(":").join("h")}
</div>
<div className={`${styles.timeWorkshop} text-start align-content-center d-inline-block px-3 py-2`}>
<small className="d-block text-muted mb-1 me-2"><LuUsers className={`${styles.iconClock} mb-1 me-2`} /> Inscritos</small>
<span className={`${styles.contagemUtilizadoresInscritos} fs-6 px-0`}>{workshop.users.length === 1 ? "1 utilizador" : `${workshop.users.length} utilizadores`} </span>
</div>
</div>
</div>
</div>
<div className="col-12 mx-auto ms-sm-0 mt-5 pt-3">
{!isAdmin ? (
currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center mx-auto py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
Anular inscrição
</button>
) : (
<button type="button" className={`${styles.btnInscrever} btn text-center mx-auto py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshop(); }} key={workshop.id} style={{ width: '180px' }}>
Inscrever-me
</button>
)
) : null}
</div>
<div className="d-flex flex-column gap-2 justify-content-center mt-3">
<p className={`${styles.description} text-start mb-3`}>{workshop.description}</p>
</div>
</div>
</>

View File

@@ -6,6 +6,7 @@
.LinkIcon, .linkText{
color: var(--text-black);
font-size: var(--size-font-text);
}
.button:hover .LinkIcon, .button:hover .linkText{
@@ -15,6 +16,7 @@
.title{
color: var(--primary-contrast-color);
font-size: var(--size-font-title);
font-weight: 900;
}
.subtitle{
@@ -22,15 +24,24 @@
font-size: var(--size-font-subtitle);
}
.thumbnailWrapper{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
height: 250px;
border-radius: var(--border-radius);
position: relative;
overflow: hidden;
}
.thumbnail{
width: 300px;
height: 250px;
object-fit: cover;
border-radius: var(--border-radius);
}
.dateWorkshop, .timeWorkshop{
background-color: var(--bg-primary-color-opacity);
background-color: var(--bg-white);
color: var(--text-black);
font-size: 18px;
font-weight: 700;
@@ -186,6 +197,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);

View File

@@ -4,11 +4,12 @@ import { Link } from "react-router";
import { CgSpinner } from "react-icons/cg";
import styles from "./styles.module.css";
import { LuPlus, LuCalendar, LuClock3, LuSettings2 } from "react-icons/lu";
import { usePreloadImages } from "../../../hooks/usePreloadImages";
import { useGetWorkshops } from "../../../hooks/useGetWorkshops";
import { useGetCurrentUser } from "../../../hooks/useGetCurrentUser";
import { useDebounce } from "../../../hooks/useDebounce";
import Swal from "sweetalert2";
import { Dropdown } from "react-bootstrap";
import { Dropdown, Form, Pagination } from "react-bootstrap";
import { imageSkeletonFadeStyle, onImageSkeletonLoad } from "../../../utils/imageSkeleton";
export default function Workshops() {
const user = JSON.parse(localStorage.getItem("user") || "{}") as User;
@@ -16,42 +17,51 @@ export default function Workshops() {
const [loading, setLoading] = useState(true);
const [selectedWorkshopStatus, setSelectedWorkshopStatus] = useState<string>("pending");
const { preloadImages } = usePreloadImages();
const { getWorkshops } = useGetWorkshops();
const [workshops, setWorkshops] = useState<Workshop[]>([]);
const { getCurrentUser } = useGetCurrentUser();
const [currentUserData, setCurrentUserData] = useState<User | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce(search, 500);
const [lastPage, setLastPage] = useState(1);
const [loadingWorkshops, setLoadingWorkshops] = useState(false);
/* const [searchLoading, setSearchLoading] = useState(false); */
useEffect(() => {
const fetchAll = async () => {
setLoading(true);
const fetchWorkshops = async () => {
setLoadingWorkshops(true);
const [workshopsData, currentUserData] = await Promise.all([
getWorkshops(),
getCurrentUser(),
]);
try {
const currentUserData = await getCurrentUser();
setCurrentUserData(currentUserData.data);
setWorkshops(workshopsData as Workshop[]);
setCurrentUserData(currentUserData?.data as User);
console.log("workshops response:", workshopsData);
const workshopsData = await getWorkshops({
page: currentPage,
search: debouncedSearch,
status: selectedWorkshopStatus,
});
await preloadImages([
...(workshopsData as Workshop[]).map((w: Workshop) => `http://127.0.0.1:8000/storage/${w.image}`),
]);
if ("workshops" in workshopsData) {
setWorkshops(workshopsData.workshops);
setLastPage(workshopsData.meta.last_page);
setCurrentPage(workshopsData.meta.current_page);
setLoadingWorkshops(false);
} else {
setWorkshops([]);
}
setLoading(false);
} catch {
setWorkshops([]);
} finally {
setLoading(false);
setLoadingWorkshops(false);
}
};
fetchAll();
}, []);
const filteredWorkshops = workshops.filter((workshop) => {
if (selectedWorkshopStatus === "all") return true;
if (selectedWorkshopStatus === "pending") return workshop.status === "pending";
if (selectedWorkshopStatus === "realized") return workshop.status === "realized";
if (selectedWorkshopStatus === "canceled") return workshop.status === "canceled";
if (selectedWorkshopStatus === "inscrito") return currentUserData && workshop.status === "pending" && workshop.users.some((user: User) => user.id === currentUserData.id);
});
fetchWorkshops();
}, [selectedWorkshopStatus, debouncedSearch, currentPage]);
/* Inscrever num workshop */
async function inscrever(workshopId: number) {
@@ -73,8 +83,18 @@ export default function Workshops() {
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
const workshopsData = await getWorkshops({
page: currentPage,
search: debouncedSearch,
status: selectedWorkshopStatus,
});
if ("workshops" in workshopsData) {
setWorkshops(workshopsData.workshops);
setLastPage(workshopsData.meta.last_page);
setCurrentPage(workshopsData.meta.current_page);
} else {
setWorkshops([]);
}
} else {
Swal.fire({
title: data.message,
@@ -105,15 +125,23 @@ export default function Workshops() {
showConfirmButton: false,
showCloseButton: true,
});
const workshops = await getWorkshops();
setWorkshops(workshops as Workshop[]);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
const workshopsData = await getWorkshops({
page: currentPage,
search: debouncedSearch,
status: selectedWorkshopStatus,
});
if ("workshops" in workshopsData) {
setWorkshops(workshopsData.workshops);
setLastPage(workshopsData.meta.last_page);
setCurrentPage(workshopsData.meta.current_page);
} else {
Swal.fire({
title: data.message,
icon: 'error',
showConfirmButton: false,
showCloseButton: true,
});
}
}
}
@@ -126,125 +154,172 @@ export default function Workshops() {
)
}
if (workshops.length === 0) {
return (
<div className="text-center mt-5 d-flex flex-column gap-2 align-items-center">
<span>Nenhum workshop encontrado</span>
{isAdmin && (
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
)}
</div>
)
} else {
return (
<div className={`${styles.container} p-4 p-lg-0`}>
<h1 className={`${styles.title} mt-1 mb-0 mb-sm-3`}>Workshops</h1>
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="row pt-3 justify-content-between d-flex flex-column-reverse flex-sm-row">
<div className="col-12 col-sm-5 col-lg-3 text-start px-2 mt-4 mt-sm-3 ">
{/* <label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label> */}
<Dropdown onSelect={(value) => {
if (value) setSelectedWorkshopStatus(value);
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100 text-center" style={{ maxWidth: '205px' }}>
<LuSettings2 /> {selectedWorkshopStatus === 'all' ? 'Todos' : selectedWorkshopStatus === 'pending' ? 'Agendados' : selectedWorkshopStatus === 'inscrito' ? 'Inscrito' : selectedWorkshopStatus === 'realized' ? 'Realizados' : 'Cancelados'}
</Dropdown.Toggle>
<Dropdown.Menu className="text-center w-100" style={{ maxWidth: '205px' }}>
<Dropdown.Item eventKey="all" active={selectedWorkshopStatus === 'all'}>
Todos
</Dropdown.Item>
<Dropdown.Item eventKey="pending" active={selectedWorkshopStatus === 'pending'}>
Agendados
</Dropdown.Item>
{!isAdmin && (
<Dropdown.Item eventKey="inscrito" active={selectedWorkshopStatus === 'inscrito'}>
Inscrito
</Dropdown.Item>
)}
<Dropdown.Item eventKey="realized" active={selectedWorkshopStatus === 'realized'}>
Realizados
</Dropdown.Item>
<Dropdown.Item eventKey="canceled" active={selectedWorkshopStatus === 'canceled'}>
Cancelados
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
{isAdmin ? (
<div className="col-12 col-sm-6 text-center text-sm-end align-content-center pe-sm-2 mt-sm-3" style={{ minHeight: '40px' }}>
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
</div>
) : null}
<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">
<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">
{/* <label htmlFor="filter" className="form-label fw-bold text-start">Filtrar workshops</label> */}
<Dropdown className="flex-grow-1" onSelect={(value) => {
setCurrentPage(1);
if (value) setSelectedWorkshopStatus(value);
}}>
<Dropdown.Toggle variant="outline-secondary" className="w-100 text-center" >
<LuSettings2 /> {selectedWorkshopStatus === 'pending' ? 'Agendados' : selectedWorkshopStatus === 'inscrito' ? 'Inscrito' : selectedWorkshopStatus === 'realized' ? 'Realizados' : 'Cancelados'}
</Dropdown.Toggle>
<Dropdown.Menu className="text-center w-100" >
<Dropdown.Item eventKey="pending" active={selectedWorkshopStatus === 'pending'}>
Agendados
</Dropdown.Item>
{!isAdmin && (
<Dropdown.Item eventKey="inscrito" active={selectedWorkshopStatus === 'inscrito'}>
Inscrito
</Dropdown.Item>
)}
<Dropdown.Item eventKey="realized" active={selectedWorkshopStatus === 'realized'}>
Realizados
</Dropdown.Item>
<Dropdown.Item eventKey="canceled" active={selectedWorkshopStatus === 'canceled'}>
Cancelados
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
{isAdmin ? (
<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' }}>
<Link to="/admin/create-workshop" className={`${styles.btnAdicionarWorkshop} text-decoration-none`}><LuPlus className="mb-1" />Adicionar workshop</Link>
</div>
) : null}
</div>
<div className="row py-3 g-4">
{filteredWorkshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<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 className="row py-3 g-4">
{loadingWorkshops ? (
<div className="col-12 text-center mt-5">
<CgSpinner className={`${styles.animateSpin} text-2xl fs-3`} />
</div>
) : workshops.length > 0 ? (
<>
{workshops.map((workshop) => (
<div className="col-12 col-sm-6 col-lg-4 p-2 position-relative">
<div className={`${styles.boxWorkshop} text-start pb-3`}>
<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>
</div>
</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 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>
</div>
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
{!isAdmin && workshop.status === "pending" ? (
<>
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
{currentUserData && workshop.users.some((user: User) => user.id === currentUserData.id) ? (
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => { cancelarInscricao(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
<div className="d-flex flex-wrap justify-content-evenly px-3 gap-2 mt-3">
{workshop.status === "realized" ? (
<>
<span className="text-success fw-bold text-center py-2 mb-0">Workshop realizado</span>
{isAdmin && (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
)}
</>
) : workshop.status === "canceled" ? (
<>
<span className="text-danger fw-bold text-center py-2 mb-0">Workshop cancelado</span>
{isAdmin && (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
)}
</>
) : workshop.status === "pending" && currentUserData && workshop.users.some((u: User) => u.id === currentUserData.id) ? (
<>
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
<button type="button" className={`${styles.btncancelarInscricao} btn text-center py-2 text-decoration-none`} onClick={() => {
cancelarInscricao(workshop.id); getWorkshops({
page: currentPage,
search: debouncedSearch,
status: selectedWorkshopStatus,
});
}} key={workshop.id} style={{ width: '180px' }}>
Anular inscrição
</button>
) : (
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => { inscrever(workshop.id); getWorkshops(); }} key={workshop.id} style={{ width: '180px' }}>
</>
) : isAdmin && workshop.status === "pending" ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : workshop.status === "pending" && !isAdmin ? (
<>
<Link to={`/workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
<button type="button" className={`${styles.btnInscrever} btn text-center py-2 px-4 text-decoration-none`} onClick={() => {
inscrever(workshop.id); getWorkshops({
page: currentPage,
search: debouncedSearch,
status: selectedWorkshopStatus,
});
}} key={workshop.id} style={{ width: '180px' }}>
Inscrever-me
</button>
)}
</>
) : workshop.status === "realized" ? (
<>
<span className="text-success fw-bold text-center py-2 mb-0">Workshop realizado</span>
{isAdmin ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : null}
</>
) : workshop.status === "canceled" ? (
<>
<span className="text-danger fw-bold text-center py-2 mb-0">Workshop cancelado</span>
{isAdmin ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : null}
</>
) : isAdmin ? (
<Link to={`/admin/edit-workshop/${workshop.id}`} className={`${styles.linkWorkshop} d-block text-center py-2 px-5 text-decoration-none`}>Detalhes</Link>
) : null}
</>
) : null}
</div>
</div>
</div>
</div>
))}
<div className="d-flex justify-content-center align-items-center gap-3 py-3">
<Pagination>
<Pagination.Prev
disabled={currentPage <= 1}
onClick={() => { setCurrentPage((p) => p - 1); setLoadingWorkshops(true); }}
/>
))}
{selectedWorkshopStatus === "pending" && filteredWorkshops.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Sem workshops agendados</span>
{currentPage > 3 && <Pagination.Item onClick={() => { setCurrentPage(1); setLoadingWorkshops(true); }}>1</Pagination.Item>}
{currentPage > 4 && <Pagination.Ellipsis disabled />}
{Array.from({ length: 5 }, (_, i) => currentPage - 2 + i)
.filter((p) => p >= 1 && p <= lastPage)
.map((p) => (
<Pagination.Item key={p} active={p === currentPage} onClick={() => { setCurrentPage(p); setLoadingWorkshops(true); }}>
{p}
</Pagination.Item>
))}
{currentPage < lastPage - 3 && <Pagination.Ellipsis disabled />}
{currentPage < lastPage - 2 && (
<Pagination.Item onClick={() => { setCurrentPage(lastPage); setLoadingWorkshops(true); }}>{lastPage}</Pagination.Item>
)}
<Pagination.Next
disabled={currentPage >= lastPage}
onClick={() => { setCurrentPage((p) => p + 1); setLoadingWorkshops(true); }}
/>
</Pagination>
</div>
) : filteredWorkshops.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum workshop encontrado com o filtro selecionado</span>
</div>
) : null}
</>
) : selectedWorkshopStatus === "pending" && workshops.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Sem workshops agendados</span>
</div>
) : workshops.length === 0 ? (
<div className="col-12 text-center mt-5">
<span className="text-muted fs-5">Nenhum workshop encontrado com o filtro selecionado</span>
</div>
) : null}
</div>
)
}
}
</div>
)
}

View File

@@ -112,18 +112,23 @@
}
.titleWorkshop{
color: var(--text-primary-color);
color: var(--primary-contrast-color);
font-size: var(--size-font-text);
font-weight: 500;
}
.thumbnailWorkshop{
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
width: 100%;
object-fit: cover;
border-top-right-radius: var(--border-radius);
border-top-left-radius: var(--border-radius);
transition: all 0.3s ease;
height: 150px;
position: relative;
overflow: hidden;
}
.icon{
@@ -134,6 +139,11 @@
animation: spin 1s linear infinite;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes spin{
from{
transform: rotate(0deg);

View File

@@ -52,6 +52,13 @@ export type CreateVideoResponse = {
data?: Video;
}
export type PaginationMeta = {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
export type Video = {
id: number;
title: string;
@@ -64,16 +71,23 @@ export type Video = {
created_at: string;
updated_at: string;
is_active: boolean;
order: number;
watched: boolean;
}
export type NextVideosResponse = {
videos: Video[];
};
export type UpdateVideoResponse = {
message?: string;
data?: Video;
}
export type GetVideoResponse = {
export type GetVideosResponse = {
message?: string;
data?: Video;
data?: Video[];
meta?: PaginationMeta;
}
export type CreateCategoryResponse = {
@@ -100,6 +114,19 @@ export type Workshop = {
users: User[];
}
export type NextWorkshopsResponse = {
workshops: Workshop[];
};
export type GetWorkshopsResponse = {
data?: Workshop[];
meta?: {
current_page: number;
last_page: number;
total: number;
};
}
export type getWorkshop = {
message?: string;
data?: Workshop;

View File

@@ -0,0 +1,10 @@
import type { CSSProperties, SyntheticEvent } from "react";
export const imageSkeletonFadeStyle: CSSProperties = {
opacity: 0,
transition: "opacity 0.3s",
};
export function onImageSkeletonLoad(e: SyntheticEvent<HTMLImageElement>) {
e.currentTarget.style.opacity = "1";
}