feat: youtube feed (#374)

- [x] add PSE youtube channel youtube feed in homepage
This commit is contained in:
Kalidou Diagne
2025-05-05 21:54:45 +03:00
committed by GitHub
parent 50a9a032b6
commit c7f8cbe0ac
11 changed files with 286 additions and 7 deletions

View File

@@ -1,6 +1,7 @@
import { Divider } from "@/components/divider"
import { NewsSection } from "@/components/sections/NewsSection"
import { WhatWeDo } from "@/components/sections/WhatWeDo"
import { HomepageVideoFeed } from "@/components/sections/HomepageVideoFeed"
import { BlogRecentArticles } from "@/components/blog/blog-recent-articles"
import { HomepageHeader } from "@/components/sections/HomepageHeader"
@@ -30,6 +31,8 @@ export default function IndexPage({ params: { lang } }: any) {
<BlogSection lang={lang} />
<HomepageVideoFeed lang={lang} />
<WhatWeDo lang={lang} />
<HomepageBanner lang={lang} />

View File

@@ -27,8 +27,6 @@ export async function GET() {
)
}
console.log("Retrieving announcements from Discord channel...")
const messagesUrl = `${Routes.channelMessages(CHANNEL_ID)}?limit=${MESSAGES_LIMIT}`
const announcements = await rest.get(messagesUrl as any)

104
app/api/youtube/route.ts Normal file
View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server"
const CHANNEL_ID = "UCh7qkafm95-kRiLMVPlbIcQ" // Privacy & Scaling Explorations channel ID
const MAX_VIDEOS = 6
export const revalidate = 3600
export async function GET() {
try {
console.log(`Fetching videos from YouTube channel: ${CHANNEL_ID}`)
const videos = await getVideosFromRSS()
return NextResponse.json({ videos })
} catch (error) {
console.error("Error fetching videos:", error)
return NextResponse.json({ videos: [] })
}
}
async function getVideosFromRSS() {
try {
const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${CHANNEL_ID}`
const response = await fetch(rssUrl, { next: { revalidate } })
if (!response.ok) {
throw new Error(`YouTube RSS feed responded with ${response.status}`)
}
const xmlData = await response.text()
const videos = parseVideosFromXml(xmlData)
return videos.slice(0, MAX_VIDEOS)
} catch (error) {
console.error("Error fetching videos from RSS feed:", error)
return []
}
}
function parseVideosFromXml(xmlData: string) {
const videos = []
const entries = []
const entryRegex = /<entry>([\s\S]*?)<\/entry>/g
let match
while ((match = entryRegex.exec(xmlData)) !== null) {
entries.push(match[1])
}
for (const entry of entries) {
try {
const videoIdMatch = entry.match(/<yt:videoId>(.*?)<\/yt:videoId>/)
if (!videoIdMatch) continue
const videoId = videoIdMatch[1]
const titleMatch = entry.match(/<title>(.*?)<\/title>/)
const title = titleMatch ? decodeHtmlEntities(titleMatch[1]) : ""
const descriptionMatch = entry.match(
/<media:description>([\s\S]*?)<\/media:description>/
)
const fullDescription = descriptionMatch
? decodeHtmlEntities(descriptionMatch[1])
: ""
const description =
fullDescription.length > 150
? fullDescription.substring(0, 150) + "..."
: fullDescription
const publishedMatch = entry.match(/<published>(.*?)<\/published>/)
const publishedAt = publishedMatch
? publishedMatch[1]
: new Date().toISOString()
const thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
videos.push({
id: videoId,
title,
description,
thumbnailUrl,
publishedAt,
channelTitle: "Privacy & Scaling Explorations",
url: `https://www.youtube.com/watch?v=${videoId}`,
})
} catch (error) {
console.warn("Error parsing video entry:", error)
}
}
return videos
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}

View File

@@ -10,5 +10,9 @@
"connectWithUs": {
"title": "Join our programs",
"description": "Want to explore the world of programmable cryptography and learn how to make contributions to open-source projects? Join our free programs to start your journey!"
}
},
"videos": "VIDEOS",
"visitOurChannel": "VISIT OUR CHANNEL",
"errorLoadingVideos": "Error loading videos",
"checkOutOurYoutube": "Check out our YouTube to learn the latest in advanced cryptography."
}

View File

@@ -179,8 +179,6 @@ const ArticlesList = ({ lang, tag }: ArticlesListProps) => {
const hasTag = tag !== undefined
console.log("hasTag", hasTag)
return (
<div className="flex flex-col gap-10 lg:gap-16">
{!hasTag && (

View File

@@ -26,6 +26,21 @@ export const Icons = {
/>
</svg>
),
play: (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="43"
height="43"
viewBox="0 0 43 43"
fill="none"
{...props}
>
<path
d="M21.1172 0.689941C9.51873 0.689941 0.117188 10.0915 0.117188 21.6899C0.117188 33.2884 9.51873 42.6899 21.1172 42.6899C32.7156 42.6899 42.1172 33.2884 42.1172 21.6899C42.1172 10.0915 32.7156 0.689941 21.1172 0.689941ZM15.3793 30.8233V12.5566L31.1988 21.6899L15.3793 30.8233Z"
fill="#E3533A"
/>
</svg>
),
eventLocation: (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -0,0 +1,127 @@
"use client"
import { useTranslation } from "@/app/i18n/client"
import { ArrowRight } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { Button } from "../ui/button"
import { AppContent } from "../ui/app-content"
import { Icons } from "../icons"
import { useYoutube } from "@/hooks/useYoutube"
interface Video {
id: string
title: string
description: string
thumbnailUrl: string
publishedAt: string
channelTitle: string
url?: string
}
const VideoCard = ({ video }: { video: Video }) => {
return (
<Link
href={video.url || `https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col gap-4"
>
<div className="relative overflow-hidden aspect-video rounded-sm">
<div className="absolute inset-0 bg-black opacity-20 group-hover:opacity-60 transition-opacity duration-300 z-10"></div>
<Image
src={video.thumbnailUrl || ""}
alt={video.title}
fill
style={{ objectFit: "cover" }}
className="transition-transform duration-300 scale-105 group-hover:scale-110"
/>
<div className="absolute inset-0 flex items-center justify-center z-20">
<Icons.play />
</div>
</div>
<h3 className="font-sans text-sm font-normal line-clamp-3 text-white group-hover:text-tuatara-400 transition-colors">
{video.title}
</h3>
</Link>
)
}
const VideoCardSkeleton = () => {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
<div className="flex flex-col gap-1">
<div className="bg-slate-200 aspect-video w-full animate-pulse"></div>
<div className="bg-slate-200 h-3 w-full animate-pulse"></div>
</div>
<div className="flex flex-col gap-1">
<div className="bg-slate-200 aspect-video w-full animate-pulse"></div>
<div className="bg-slate-200 h-3 w-full animate-pulse"></div>
</div>
<div className="flex flex-col gap-1">
<div className="bg-slate-200 aspect-video w-full animate-pulse"></div>
<div className="bg-slate-200 h-3 w-full animate-pulse"></div>
</div>
</div>
)
}
export const HomepageVideoFeed = ({ lang }: { lang: string }) => {
const { t } = useTranslation(lang, "homepage")
const { data: videos = [], isLoading, isError } = useYoutube()
return (
<section className="mx-auto px-6 lg:px-8 py-10 lg:py-16 bg-tuatara-950">
<AppContent className="flex flex-col gap-8 lg:max-w-[1200px] w-full">
<div className="col-span-1 lg:col-span-4">
<h2 className="text-base text-center font-sans font-bold text-white">
{t("videos") || "VIDEOS"}
</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-10 lg:gap-8 lg:divide-x divide-[#626262]">
<div className="lg:col-span-3">
{isLoading ? (
<VideoCardSkeleton />
) : isError ? (
<div className="flex flex-col gap-4 items-center w-full">
<VideoCardSkeleton />
<p className="text-center text-destructive">
{t("errorLoadingVideos") || "Error loading videos"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{videos.slice(0, 3).map((video: Video) => (
<VideoCard key={video.id} video={video} />
))}
</div>
)}
</div>
<div className="lg:col-span-1">
<div className="lg:p-6 flex flex-col gap-8">
<span className="text-base text-white">
{t("checkOutOurYoutube") ||
"Check out our YouTube to learn the latest in advanced cryptography."}
</span>
<Link
href="https://www.youtube.com/@privacyscalingexplorations-1"
target="_blank"
rel="noopener noreferrer"
className="group inline-flex"
>
<Button className="w-full" variant="orange">
<div className="flex items-center gap-1">
<span className="text-base font-medium uppercase">
{t("visitOurChannel") || "VISIT OUR CHANNEL"}
</span>
<ArrowRight className="h-5 w-5 duration-200 ease-in-out group-hover:translate-x-2" />
</div>
</Button>
</Link>
</div>
</div>
</div>
</AppContent>
</section>
)
}

View File

@@ -31,7 +31,7 @@ export const WhatWeDo = ({ lang }: LangProps["params"]) => {
return (
<div className="bg-cover-gradient">
<AppContent className="mx-auto">
<AppContent className="mx-auto lg:max-w-[1200px] w-full">
<section className="flex flex-col gap-16 py-16 md:pb-24">
<div className="flex flex-col text-center">
<h6 className="py-6 font-sans text-base font-bold uppercase tracking-[4px] text-tuatara-950">

View File

@@ -6,11 +6,12 @@ import { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"font-sans inline-flex items-center justify-center duration-100 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
"font-sans inline-flex items-center justify-center duration-200 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
orange: "bg-orangeDark text-white hover:bg-orangeDark/80",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:

26
hooks/useYoutube.ts Normal file
View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query"
async function fetchYoutubeVideos() {
try {
const response = await fetch("/api/youtube", {
cache: "default",
})
if (!response.ok) {
throw new Error(`Failed to fetch videos: ${response.status}`)
}
const data = await response.json()
return data.videos || []
} catch (error) {
console.error("Error fetching videos:", error)
return []
}
}
export const useYoutube = () => {
return useQuery({
queryKey: ["pse-youtube-videos"],
queryFn: () => fetchYoutubeVideos(),
})
}

View File

@@ -24,6 +24,9 @@ const withMDX = nextMdx({
const nextConfig = {
pageExtensions: ["js", "jsx", "mdx", "ts", "tsx", "md"],
reactStrictMode: true,
images: {
domains: ["i.ytimg.com"],
},
experimental: {
mdxRs: true,