diff --git a/backend/app/models/video.py b/backend/app/models/video.py index 3efc52e..e8c6cff 100644 --- a/backend/app/models/video.py +++ b/backend/app/models/video.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, ConfigDict @@ -10,6 +10,7 @@ class VideoHighlight(BaseModel): date: str video_url: Optional[str] = None thumbnail_url: Optional[str] = None + video_type: Optional[Literal["regular", "short"]] = "regular" model_config = ConfigDict( json_schema_extra={ @@ -19,6 +20,7 @@ class VideoHighlight(BaseModel): "date": "2025-11-08", "video_url": "https://youtube.com/watch?v=...", "thumbnail_url": "https://example.com/thumbnail.jpg", + "video_type": "regular", } } ) diff --git a/backend/app/services/video_service.py b/backend/app/services/video_service.py index 1cf3d93..1d57dd3 100644 --- a/backend/app/services/video_service.py +++ b/backend/app/services/video_service.py @@ -22,8 +22,9 @@ def get_all_videos(self) -> List[VideoHighlight]: "date": 1, "video_url": 1, "thumbnail_url": 1, + "video_type": 1, }, - ) + ).sort("date", -1) ) results: List[VideoHighlight] = [] for d in docs: @@ -34,6 +35,7 @@ def get_all_videos(self) -> List[VideoHighlight]: date=d.get("date"), video_url=d.get("video_url"), thumbnail_url=d.get("thumbnail_url"), + video_type=d.get("video_type", "regular"), ) results.append(vid) return results @@ -52,6 +54,7 @@ def get_video_by_id(self, video_id: str) -> Optional[VideoHighlight]: "date": 1, "video_url": 1, "thumbnail_url": 1, + "video_type": 1, }, ) if not doc: @@ -63,6 +66,7 @@ def get_video_by_id(self, video_id: str) -> Optional[VideoHighlight]: date=doc.get("date"), video_url=doc.get("video_url"), thumbnail_url=doc.get("thumbnail_url"), + video_type=doc.get("video_type", "regular"), ) doc = self.videos_collection.find_one( @@ -74,6 +78,7 @@ def get_video_by_id(self, video_id: str) -> Optional[VideoHighlight]: "date": 1, "video_url": 1, "thumbnail_url": 1, + "video_type": 1, }, ) if not doc: @@ -85,6 +90,7 @@ def get_video_by_id(self, video_id: str) -> Optional[VideoHighlight]: date=doc.get("date"), video_url=doc.get("video_url"), thumbnail_url=doc.get("thumbnail_url"), + video_type=doc.get("video_type", "regular"), ) def create_video(self, video: VideoHighlight) -> VideoHighlight: diff --git a/src/components/admin/VideoManagement.tsx b/src/components/admin/VideoManagement.tsx index 64c55c2..c3d1ff9 100644 --- a/src/components/admin/VideoManagement.tsx +++ b/src/components/admin/VideoManagement.tsx @@ -5,6 +5,13 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Card } from "@/components/ui/card"; import { Plus, Trash, Pencil, VideoCamera } from "@phosphor-icons/react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { AlertDialog, AlertDialogTrigger, @@ -33,6 +40,7 @@ export default function VideoManagement() { date: new Date().toISOString().split("T")[0], videoUrl: "", thumbnailUrl: "", + videoType: "regular" as "regular" | "short", }); // Site links editable in admin. Initialize with stable defaults to avoid @@ -51,6 +59,7 @@ export default function VideoManagement() { date: new Date().toISOString().split("T")[0], videoUrl: "", thumbnailUrl: "", + videoType: "regular", }); setEditingId(null); }; @@ -95,6 +104,7 @@ export default function VideoManagement() { date: video.date, videoUrl: video.videoUrl, thumbnailUrl: video.thumbnailUrl || "", + videoType: video.videoType || "regular", }); setEditingId(video.id); }; @@ -121,6 +131,7 @@ export default function VideoManagement() { date: formData.date, videoUrl: formData.videoUrl, thumbnailUrl: formData.thumbnailUrl || undefined, + videoType: formData.videoType, }); toast.success("Video updated successfully"); } else { @@ -130,6 +141,7 @@ export default function VideoManagement() { date: formData.date, videoUrl: formData.videoUrl, thumbnailUrl: formData.thumbnailUrl || undefined, + videoType: formData.videoType, }); toast.success("Video added successfully"); } @@ -271,20 +283,38 @@ export default function VideoManagement() { />
- - - setFormData({ ...formData, videoUrl: e.target.value }) + +
+
+ + + setFormData({ ...formData, videoUrl: e.target.value }) + } + placeholder="https://youtube.com/watch?v=..." + required + /> +
+
({}); + // Pagination state for regular videos + const [currentPage, setCurrentPage] = useState(0); + const videosPerPage = 4; + + // State for shorts section + const [currentShortIndex, setCurrentShortIndex] = useState(0); + const shortsContainerRef = useRef(null); + const [touchStart, setTouchStart] = useState(0); + useEffect(() => { const load = async () => { try { @@ -48,16 +64,24 @@ export default function VideosPage() { load(); }, []); - const getEmbedUrl = (url: string) => { + const getEmbedUrl = (url: string, autoplay = false) => { if (url.includes("youtube.com") || url.includes("youtu.be")) { const videoId = url.includes("youtu.be") ? url.split("youtu.be/")[1]?.split("?")[0] : url.split("v=")[1]?.split("&")[0]; - return `https://www.youtube.com/embed/${videoId}`; + return `https://www.youtube.com/embed/${videoId}${autoplay ? "?autoplay=1&mute=1&loop=1&playlist=" + videoId : ""}`; } return url; }; + const getShortsEmbedUrl = (url: string) => { + if (url.includes("youtube.com/shorts/")) { + const videoId = url.split("shorts/")[1]?.split("?")[0]; + return `https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1&loop=1&playlist=${videoId}`; + } + return getEmbedUrl(url, true); + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("pt-PT", { month: "long", @@ -104,6 +128,53 @@ export default function VideosPage() { ); } + // Separate videos by type and sort by date (most recent first) + const regularVideos = videos + .filter((v) => !v.videoType || v.videoType === "regular") + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const shorts = videos + .filter((v) => v.videoType === "short") + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + // Pagination for regular videos + const totalPages = Math.ceil(regularVideos.length / videosPerPage); + const startIndex = currentPage * videosPerPage; + const endIndex = startIndex + videosPerPage; + const currentVideos = regularVideos.slice(startIndex, endIndex); + + // Handlers for shorts navigation + const handlePrevShort = () => { + if (currentShortIndex > 0) { + setCurrentShortIndex(currentShortIndex - 1); + } + }; + + const handleNextShort = () => { + if (currentShortIndex < shorts.length - 1) { + setCurrentShortIndex(currentShortIndex + 1); + } + }; + + // Touch handlers for mobile swipe + const handleTouchStart = (e: React.TouchEvent) => { + setTouchStart(e.touches[0].clientY); + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + const touchEnd = e.changedTouches[0].clientY; + const diff = touchStart - touchEnd; + + // Swipe down (diff < 0) goes to previous, swipe up (diff > 0) goes to next + if (Math.abs(diff) > 50) { + if (diff > 0) { + handleNextShort(); + } else { + handlePrevShort(); + } + } + }; + return (
@@ -116,50 +187,175 @@ export default function VideosPage() {

-
- {videos.map((video) => ( - setSelectedVideo(video)} - > -
- {video.thumbnailUrl ? ( - {video.title} - ) : ( - - )} -
- +
+ {/* Left side: Regular videos in 2x2 grid */} +
+
+

Vídeos

+ {totalPages > 1 && ( +
+ + + {currentPage + 1} / {totalPages} + +
+ )} +
+ + {regularVideos.length === 0 ? ( + +

Ainda sem vídeos regulares

+
+ ) : ( +
+ {currentVideos.map((video) => ( + setSelectedVideo(video)} + > +
+ {video.thumbnailUrl ? ( + {video.title} + ) : ( + + )} +
+ +
+
+
+

+ {video.title} +

+
+

+ {video.description} +

+
+ + {formatDate(video.date)} +
+
+ + ))}
-
-

- {video.title} -

-
-

- {video.description} -

-
- - {formatDate(video.date)} -
+ )} +
+ + {/* Right side: Shorts section */} +
+
+
+

Shorts

+ {shorts.length > 1 && ( + + {currentShortIndex + 1} / {shorts.length} + + )}
- - ))} + + {shorts.length === 0 ? ( + +

Ainda sem shorts

+
+ ) : ( +
+
+
+