From c7f8cbe0ac42f2457a6cfdfa33e1401a95ed0cc9 Mon Sep 17 00:00:00 2001 From: Kalidou Diagne Date: Mon, 5 May 2025 21:54:45 +0300 Subject: [PATCH] feat: youtube feed (#374) - [x] add PSE youtube channel youtube feed in homepage --- app/[lang]/page.tsx | 3 + app/api/news/route.ts | 2 - app/api/youtube/route.ts | 104 ++++++++++++++++++ app/i18n/locales/en/homepage.json | 6 +- components/blog/ArticlesList.tsx | 2 - components/icons.tsx | 15 +++ components/sections/HomepageVideoFeed.tsx | 127 ++++++++++++++++++++++ components/sections/WhatWeDo.tsx | 2 +- components/ui/button.tsx | 3 +- hooks/useYoutube.ts | 26 +++++ next.config.mjs | 3 + 11 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 app/api/youtube/route.ts create mode 100644 components/sections/HomepageVideoFeed.tsx create mode 100644 hooks/useYoutube.ts diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index f14a465..bb70fc6 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -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) { + + diff --git a/app/api/news/route.ts b/app/api/news/route.ts index a188367..9fee955 100644 --- a/app/api/news/route.ts +++ b/app/api/news/route.ts @@ -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) diff --git a/app/api/youtube/route.ts b/app/api/youtube/route.ts new file mode 100644 index 0000000..1a56c6c --- /dev/null +++ b/app/api/youtube/route.ts @@ -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 = /([\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>/) + if (!videoIdMatch) continue + const videoId = videoIdMatch[1] + + const titleMatch = entry.match(/(.*?)<\/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(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") +} diff --git a/app/i18n/locales/en/homepage.json b/app/i18n/locales/en/homepage.json index 98bfd17..db0d845 100644 --- a/app/i18n/locales/en/homepage.json +++ b/app/i18n/locales/en/homepage.json @@ -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." } diff --git a/components/blog/ArticlesList.tsx b/components/blog/ArticlesList.tsx index d123213..3b7fbe2 100644 --- a/components/blog/ArticlesList.tsx +++ b/components/blog/ArticlesList.tsx @@ -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 && ( diff --git a/components/icons.tsx b/components/icons.tsx index a24bdcb..0a86daa 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -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" diff --git a/components/sections/HomepageVideoFeed.tsx b/components/sections/HomepageVideoFeed.tsx new file mode 100644 index 0000000..7835b75 --- /dev/null +++ b/components/sections/HomepageVideoFeed.tsx @@ -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> + ) +} diff --git a/components/sections/WhatWeDo.tsx b/components/sections/WhatWeDo.tsx index bdda9ae..610f63c 100644 --- a/components/sections/WhatWeDo.tsx +++ b/components/sections/WhatWeDo.tsx @@ -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"> diff --git a/components/ui/button.tsx b/components/ui/button.tsx index e1c6b42..5882c1d 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -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: diff --git a/hooks/useYoutube.ts b/hooks/useYoutube.ts new file mode 100644 index 0000000..a6e5b56 --- /dev/null +++ b/hooks/useYoutube.ts @@ -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(), + }) +} diff --git a/next.config.mjs b/next.config.mjs index 696f9a4..e9d4f86 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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,