diff --git a/app/[lang]/projects/sections/ProjectContent.tsx b/app/[lang]/projects/sections/ProjectContent.tsx index 2ced6b9..1754b89 100644 --- a/app/[lang]/projects/sections/ProjectContent.tsx +++ b/app/[lang]/projects/sections/ProjectContent.tsx @@ -20,6 +20,8 @@ import { WikiSideNavigation } from "@/components/wiki-side-navigation" import { useTranslation } from "@/app/i18n/client" import { LocaleTypes } from "@/app/i18n/settings" import { ProjectBlogArticles } from "@/components/blog/project-blog-articles" +import { ProjectYouTubeVideos } from "@/components/sections/ProjectYouTubeVideos" +import { ProjectTeamMembers } from "@/components/project/project-team" export const ProjectContent = ({ id, @@ -232,8 +234,25 @@ export const ProjectContent = ({ {content?.description} )} + + {project?.youtubeLinks && + project.youtubeLinks.length > 0 && ( +
+ +
+ )} + + {project?.team && project.team.length > 0 && ( +
+ +
+ )} + diff --git a/app/api/youtube/videos/route.ts b/app/api/youtube/videos/route.ts new file mode 100644 index 0000000..2d46357 --- /dev/null +++ b/app/api/youtube/videos/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server" + +interface YoutubeVideoResponse { + items: { + id: string + snippet: { + title: string + description: string + publishedAt: string + channelTitle: string + thumbnails: { + high: { + url: string + } + standard?: { + url: string + } + maxres?: { + url: string + } + } + } + }[] +} + +// Helper function to add CORS headers +function corsHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + } +} + +// Handle OPTIONS requests for CORS preflight +export async function OPTIONS() { + return NextResponse.json({}, { headers: corsHeaders() }) +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const ids = searchParams.get("ids") + + if (!ids) { + return NextResponse.json( + { error: "No video IDs provided" }, + { status: 400, headers: corsHeaders() } + ) + } + + // API key should be in env variables + const apiKey = process.env.YOUTUBE_API_KEY + + if (!apiKey) { + return NextResponse.json( + { error: "YouTube API key not configured" }, + { status: 500, headers: corsHeaders() } + ) + } + + const youtubeApiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet&id=${ids}&key=${apiKey}` + + const response = await fetch(youtubeApiUrl) + + if (!response.ok) { + throw new Error(`YouTube API responded with status: ${response.status}`) + } + + const data: YoutubeVideoResponse = await response.json() + + const formattedVideos = data.items.map((item) => { + // Get best available thumbnail + const thumbnailUrl = + item.snippet.thumbnails.maxres?.url || + item.snippet.thumbnails.standard?.url || + item.snippet.thumbnails.high.url + + return { + id: item.id, + title: item.snippet.title, + description: item.snippet.description, + thumbnailUrl, + publishedAt: item.snippet.publishedAt, + channelTitle: item.snippet.channelTitle, + } + }) + + return NextResponse.json(formattedVideos, { headers: corsHeaders() }) + } catch (error) { + console.error("Error fetching YouTube videos:", error) + return NextResponse.json( + { error: "Failed to fetch videos from YouTube API" }, + { status: 500, headers: corsHeaders() } + ) + } +} diff --git a/app/i18n/locales/en/common.json b/app/i18n/locales/en/common.json index e5b4837..3c0de0a 100644 --- a/app/i18n/locales/en/common.json +++ b/app/i18n/locales/en/common.json @@ -99,5 +99,9 @@ "joinOurDiscord": "Join our discord", "prevBrandImage": "Previous branding", "editThisPage": "Edit this page", - "contents": "Contents" + "contents": "Contents", + "projectVideos": "Project Videos", + "errorLoadingVideos": "Error loading videos", + "projectTeam": "Team", + "youtubeVideos": "YouTube Videos" } diff --git a/components/icons.tsx b/components/icons.tsx index 0a86daa..3658657 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -11,6 +11,21 @@ export type Icon = LucideIcon export const Icons = { sun: SunMedium, + email: (props: LucideProps) => ( + + + + + ), time: (props: LucideProps) => ( { + const { t } = useTranslation(lang, "common") + + if (!team || team.length === 0) { + return null + } + + return ( +
+

+ {t("projectTeam") || "Project Team"} +

+ +
+ {team.map((member, index) => ( +
+
+ {member.image ? ( +
+ {member.name} +
+ ) : ( +
+ )} +
+

+ {member.name} +

+ {member.role && ( +

+ {member.role} +

+ )} +
+
+ + {member.email && ( + + + {member.email} + + )} + + {member.links && Object.keys(member.links).length > 0 && ( +
+ {Object.entries(member.links).map(([key, value]) => ( + + {ProjectLinkIconMap?.[key as ProjectLinkWebsite]} + {key} + + ))} +
+ )} +
+ ))} +
+
+ ) +} diff --git a/components/sections/ProjectYouTubeVideos.tsx b/components/sections/ProjectYouTubeVideos.tsx new file mode 100644 index 0000000..ce03c74 --- /dev/null +++ b/components/sections/ProjectYouTubeVideos.tsx @@ -0,0 +1,126 @@ +"use client" + +import { useTranslation } from "@/app/i18n/client" +import Image from "next/image" +import Link from "next/link" +import { Icons } from "../icons" +import { useEffect, useState } from "react" + +interface VideoCardProps { + videoId: string +} + +const VideoCard = ({ videoId }: VideoCardProps) => { + const [title, setTitle] = useState("") + const [isLoading, setIsLoading] = useState(true) + const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` + + useEffect(() => { + // Use YouTube's oEmbed API to get video title (no CORS issues) + fetch( + `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json` + ) + .then((response) => response.json()) + .then((data) => { + setTitle(data.title) + setIsLoading(false) + }) + .catch(() => { + // If oEmbed fails, we'll just show "YouTube Video" + setIsLoading(false) + }) + }, [videoId]) + + return ( + +
+
+ {title { + // Fallback to medium quality if maxresdefault is not available + const target = e.target as HTMLImageElement + target.src = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` + }} + /> +
+ +
+
+

+ {isLoading ? ( +
+ ) : ( + title || "YouTube Video" + )} +

+ + ) +} + +export const ProjectYouTubeVideos = ({ + youtubeLinks, + lang, +}: { + youtubeLinks: string[] + lang: string +}) => { + const { t } = useTranslation(lang, "common") + const [videoIds, setVideoIds] = useState([]) + + const extractVideoId = (url: string): string => { + if (url.includes("youtube.com/watch?v=")) { + const urlObj = new URL(url) + return urlObj.searchParams.get("v") || "" + } else if (url.includes("youtu.be/")) { + return url.split("youtu.be/")[1]?.split("?")[0] || "" + } else if (/^[a-zA-Z0-9_-]{11}$/.test(url)) { + return url + } + return "" + } + + useEffect(() => { + if (!youtubeLinks || youtubeLinks.length === 0) { + return + } + + // Extract valid video IDs + const ids = youtubeLinks.map(extractVideoId).filter((id) => id) + + setVideoIds(ids) + }, [youtubeLinks]) + + if (!youtubeLinks || youtubeLinks.length === 0 || videoIds.length === 0) { + return null + } + + return ( +
+
+

+ {t("projectVideos") || "Project Videos"} +

+ +
+ {videoIds.map((videoId) => ( + + ))} +
+
+
+ ) +} diff --git a/components/wiki-side-navigation.tsx b/components/wiki-side-navigation.tsx index d8b2167..7f1db29 100644 --- a/components/wiki-side-navigation.tsx +++ b/components/wiki-side-navigation.tsx @@ -158,9 +158,14 @@ export const WikiSideNavigation = ({ }, } - const { extraLinks = {} } = project + const { extraLinks = {}, team = [], youtubeLinks = [] } = project const hasRelatedArticles = articles.length > 0 && !loading + const hasTeam = Array.isArray(team) && team.length > 0 + const hasYoutubeVideos = + Array.isArray(youtubeLinks) && youtubeLinks.length > 0 + + console.log(hasTeam, hasYoutubeVideos, youtubeLinks) if (sections.length === 0 || content.length === 0) return null @@ -195,6 +200,27 @@ export const WikiSideNavigation = ({ /> ) })} + + {hasYoutubeVideos && ( + scrollToSection("youtube-videos")} + activeSection={activeSection} + text={t("youtubeVideos") || "YouTube Videos"} + id="youtube-videos" + /> + )} + + {hasTeam && ( + scrollToSection("team")} + activeSection={activeSection} + text={t("projectTeam") || "Team"} + id="team" + /> + )} + {hasRelatedArticles && (