mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-01-09 14:18:02 -05:00
feat: youtube feed (#374)
- [x] add PSE youtube channel youtube feed in homepage
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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
104
app/api/youtube/route.ts
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
127
components/sections/HomepageVideoFeed.tsx
Normal file
127
components/sections/HomepageVideoFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
26
hooks/useYoutube.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user