diff --git a/app/api/stories/videos/route.ts b/app/api/stories/videos/route.ts new file mode 100644 index 0000000..fa41507 --- /dev/null +++ b/app/api/stories/videos/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { getVideoStories } from '@/lib/stories'; + +// Read at request time (the build has no database), then cached at the edge. +export const dynamic = 'force-dynamic'; + +// GET: public video stories for the rail on the homepage and program pages +export async function GET() { + const videos = await getVideoStories(); + return NextResponse.json(videos, { + headers: { 'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=86400' }, + }); +} diff --git a/app/testimonials/page.tsx b/app/testimonials/page.tsx index 119b624..a27966a 100644 --- a/app/testimonials/page.tsx +++ b/app/testimonials/page.tsx @@ -1,8 +1,5 @@ -import connectDB from '@/lib/db'; -import connectImpactDB from '@/lib/impact-db'; -import { Testimonial as TestimonialModel } from '@/models/Testimonial'; -import { getImpactStoryModel } from '@/models/ImpactStory'; import { getVideoEmbed } from '@/lib/video-url'; +import { getPublicStories, withPreviews } from '@/lib/stories'; import { SubmitImpactStoryModal } from '@/components/submit-impact-story-modal'; import { TestimonialRail } from '@/components/testimonial-rail'; import type { Testimonial } from '@/components/testimonial-card'; @@ -10,38 +7,6 @@ import { Card, Eyebrow, PageHero, Panel, Section } from '@/components/site/page- export const dynamic = 'force-dynamic'; -async function getTestimonials() { - try { - await connectDB(); - const testimonials = await TestimonialModel.find({ isApproved: true }) - .select('name role before content cta videoUrl imageUrl type createdAt') - .sort({ createdAt: -1 }); - return JSON.parse(JSON.stringify(testimonials)); - } catch (error) { - console.error('Unable to load testimonials:', error); - return []; - } -} - -async function getPublicImpactStories() { - try { - const connection = await connectImpactDB(); - const ImpactStory = getImpactStoryModel(connection); - const stories = await ImpactStory.find({ - isApproved: true, - visibility: { $in: ['public', 'featured'] }, - }) - .select('name role program cohort sponsor before content cta videoUrl imageUrl type visibility featuredRank createdAt') - .sort({ featuredRank: 1, createdAt: -1 }) - .lean(); - - return JSON.parse(JSON.stringify(stories)); - } catch (error) { - console.error('Unable to load public impact stories:', error); - return []; - } -} - export const metadata = { alternates: { canonical: "/testimonials" }, title: 'Stories from our community', @@ -49,16 +14,12 @@ export const metadata = { }; export default async function TestimonialsPage() { - const [legacyTestimonials, impactStories] = await Promise.all([ - getTestimonials(), - getPublicImpactStories(), - ]); - const testimonials: Testimonial[] = [...legacyTestimonials, ...impactStories].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); + const testimonials: Testimonial[] = await getPublicStories(); // Video stories first. A "video" story whose link cannot be embedded reads as a written story. - const videoTestimonials = testimonials.filter((t) => t.type === 'video' && getVideoEmbed(t.videoUrl)); - const textTestimonials = testimonials.filter((t) => !videoTestimonials.includes(t)); + const videoTestimonials = await withPreviews( + testimonials.filter((t) => t.type === 'video' && getVideoEmbed(t.videoUrl)), + ); + const textTestimonials = testimonials.filter((t) => !(t.type === 'video' && getVideoEmbed(t.videoUrl))); return ( <> diff --git a/components/home/data.ts b/components/home/data.ts index fb25c64..0177225 100644 --- a/components/home/data.ts +++ b/components/home/data.ts @@ -358,11 +358,11 @@ export const videoStories: { name: string; role: string; url: string | null }[] role: "How Dev Weekends members cracked GSoC, ESoC and more", url: "https://www.youtube.com/watch?v=eP25H9HX8D8", }, - { name: "Muhammad Saqlain", role: "GSoC 2026, NumFOCUS", url: null }, - { name: "Mateeb Haider", role: "GSoC 2026, FOSSASIA", url: null }, - { name: "Muhammad Junaid Shaukat", role: "GSoC 2026, Apache Beam", url: null }, - { name: "Aqib Nawab", role: "GSoC 2026, C2SI", url: null }, - { name: "Kamran ul Haq", role: "GSoC 2026, FLARE", url: null }, + { name: "Muhammad Saqlain", role: "GSoC 2026, NumFOCUS", url: "https://youtu.be/7mtCxDtcYvA" }, + { name: "Mateeb Haider", role: "GSoC 2026, FOSSASIA", url: "https://youtu.be/VQHqeFUKvF8" }, + { name: "Muhammad Junaid Shaukat", role: "GSoC 2026, Apache Beam", url: "https://youtu.be/6SfzvbiRspg" }, + { name: "Aqib Nawab", role: "GSoC 2026, C2SI", url: "https://youtu.be/N_PP62MsaEE" }, + { name: "Kamran ul Haq", role: "GSoC 2026, FLARE", url: "https://youtu.be/v01zfRuV9Vw" }, ] export const voices = [ diff --git a/components/home/voices.tsx b/components/home/voices.tsx index a3c9fac..46a73a5 100644 --- a/components/home/voices.tsx +++ b/components/home/voices.tsx @@ -1,24 +1,42 @@ "use client" -import { useRef, useState } from "react" +import { useEffect, useRef, useState } from "react" import Link from "next/link" import { ArrowLeft, ArrowRight, Play } from "lucide-react" import { getVideoEmbed } from "@/lib/video-url" import { LINKS, videoStories, voices } from "./data" +type LiveVideo = { name: string; role: string; url: string; previewUrl?: string } + /** - * Stories rail: videos first, then written quotes. It does not move by itself. - * People scroll it with the arrows, a swipe or the trackpad. + * Stories rail: video stories, loaded from the same approved stories as the + * stories page (GSoC and LFX first), after the videos listed in data.ts. + * Written quotes only fill in when there are too few videos. It does not move + * by itself: people scroll it with the arrows, a swipe or the trackpad. */ export function VoicesRail() { const rail = useRef(null) const [playing, setPlaying] = useState(null) + const [live, setLive] = useState([]) + + useEffect(() => { + fetch("/api/stories/videos") + .then((res) => (res.ok ? res.json() : [])) + .then((data) => setLive(Array.isArray(data) ? data : [])) + .catch(() => setLive([])) + }, []) - const videos = videoStories.flatMap((story) => { + const seen = new Set() + const listed: { name: string; role: string; url: string | null; previewUrl?: string }[] = [...videoStories, ...live] + const videos = listed.flatMap((story) => { const embed = getVideoEmbed(story.url ?? undefined) - return embed ? [{ ...story, embed }] : [] + if (!embed || seen.has(embed.embedUrl)) return [] + seen.add(embed.embedUrl) + const preview = story.previewUrl ?? embed.thumbnailUrl + return [{ name: story.name, role: story.role, embed, preview }] }) + const quotes = videos.length < 3 ? voices : [] const scrollBy = (direction: 1 | -1) => { const el = rail.current @@ -64,9 +82,9 @@ export function VoicesRail() { className="hm-scroll mt-12 flex snap-x snap-mandatory gap-5 overflow-x-auto scroll-smooth px-[max(24px,calc((100%-1200px)/2+24px))] scroll-px-[max(24px,calc((100%-1200px)/2+24px))] pb-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#0A0A0A]" > {videos.map((video) => ( -
+
- {playing === video.name ? ( + {playing === video.embed.embedUrl ? (