From e04b28ede929b6cd3d30df2c42add21e2638af9d Mon Sep 17 00:00:00 2001 From: Zeeshan Adil Date: Wed, 28 Oct 2026 03:04:26 +0500 Subject: [PATCH 1/2] fix(stories): show previews for Google Drive and Loom videos Only YouTube links had a thumbnail, so Drive and Loom video stories showed a blank avatar. Drive uses its thumbnail link; Loom's thumbnail URL carries a hash, so it is read from Loom's oEmbed endpoint on the server and cached for a day. Story loading moves to lib/stories.ts so other pages can reuse it. --- app/testimonials/page.tsx | 51 ++-------------- components/testimonial-card.tsx | 4 +- lib/stories.ts | 101 ++++++++++++++++++++++++++++++++ lib/video-url.ts | 1 + 4 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 lib/stories.ts 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/testimonial-card.tsx b/components/testimonial-card.tsx index c5c774a..b9189d5 100644 --- a/components/testimonial-card.tsx +++ b/components/testimonial-card.tsx @@ -13,6 +13,8 @@ export interface Testimonial { content: string; cta?: string; videoUrl?: string; + /** Preview image resolved on the server, for providers without a fixed thumbnail URL */ + previewUrl?: string; imageUrl?: string; type: 'text' | 'video'; program?: string; @@ -53,7 +55,7 @@ export function TestimonialCard({ testimonial }: { testimonial: Testimonial }) { const program = formatProgram(testimonial.program); const video = getVideoEmbed(testimonial.videoUrl); - const thumbnailUrl = video?.thumbnailUrl ?? '/avatar.svg'; + const thumbnailUrl = testimonial.previewUrl ?? video?.thumbnailUrl ?? '/avatar.svg'; if (testimonial.type === 'video' && video) { return ( diff --git a/lib/stories.ts b/lib/stories.ts new file mode 100644 index 0000000..7ea338f --- /dev/null +++ b/lib/stories.ts @@ -0,0 +1,101 @@ +import connectDB from '@/lib/db'; +import connectImpactDB from '@/lib/impact-db'; +import { getVideoEmbed } from '@/lib/video-url'; +import { Testimonial as TestimonialModel } from '@/models/Testimonial'; +import { getImpactStoryModel } from '@/models/ImpactStory'; +import type { Testimonial } from '@/components/testimonial-card'; + +/** + * Public community stories, from both the original testimonials and the newer + * impact stories. The stories page and the video rail on other pages read from + * here, so a story approved in the admin shows up everywhere. + */ + +async function getLegacyTestimonials(): Promise { + 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(): Promise { + 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 []; + } +} + +/** Newest first, across both sources */ +export async function getPublicStories(): Promise { + const [legacy, impact] = await Promise.all([getLegacyTestimonials(), getPublicImpactStories()]); + return [...legacy, ...impact].sort( + (a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(), + ); +} + +/** + * A preview image for a video link. YouTube and Google Drive have fixed + * thumbnail URLs; Loom's includes a hash, so it is read from Loom's oEmbed + * endpoint and cached for a day. + */ +export async function getVideoPreview(videoUrl?: string): Promise { + const embed = getVideoEmbed(videoUrl); + if (!embed) return undefined; + if (embed.thumbnailUrl) return embed.thumbnailUrl; + if (embed.provider !== 'Loom' || !videoUrl) return undefined; + + try { + const res = await fetch(`https://www.loom.com/v1/oembed?url=${encodeURIComponent(videoUrl)}`, { + next: { revalidate: 86400 }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return undefined; + const data = (await res.json()) as { thumbnail_url?: unknown }; + const thumbnail = typeof data.thumbnail_url === 'string' ? data.thumbnail_url : undefined; + return thumbnail?.startsWith('https://cdn.loom.com/') ? thumbnail : undefined; + } catch { + return undefined; + } +} + +/** Adds `previewUrl` to every video story */ +export async function withPreviews(stories: Testimonial[]): Promise { + return Promise.all( + stories.map(async (story) => + story.type === 'video' ? { ...story, previewUrl: await getVideoPreview(story.videoUrl) } : story, + ), + ); +} + +export type VideoStory = { name: string; role: string; url: string; previewUrl?: string }; + +/** Open source stories (GSoC, LFX and similar) lead the video rail */ +function openSourceRank(role: string) { + return /gsoc|summer of code|lfx|esoc/i.test(role) ? 0 : 1; +} + +export async function getVideoStories(): Promise { + const stories = await getPublicStories(); + const videos = stories.filter((story) => story.type === 'video' && getVideoEmbed(story.videoUrl)); + const withImages = await withPreviews(videos); + return withImages + .map((story) => ({ name: story.name, role: story.role, url: story.videoUrl as string, previewUrl: story.previewUrl })) + .sort((a, b) => openSourceRank(a.role) - openSourceRank(b.role)); +} diff --git a/lib/video-url.ts b/lib/video-url.ts index e906774..1cfc48b 100644 --- a/lib/video-url.ts +++ b/lib/video-url.ts @@ -60,6 +60,7 @@ export function getVideoEmbed(value?: string): VideoEmbed | null { ? { provider: 'Google Drive', embedUrl: `https://drive.google.com/file/d/${id}/preview`, + thumbnailUrl: `https://drive.google.com/thumbnail?id=${id}&sz=w800`, } : null; } From 0de6b83757cd8ca0f09a479a08903b97c13cda2c Mon Sep 17 00:00:00 2001 From: Zeeshan Adil Date: Wed, 28 Oct 2026 03:04:26 +0500 Subject: [PATCH 2/2] feat(home): show video stories instead of written quotes The voices rail now loads every approved video story through /api/stories/videos, GSoC and LFX first, after the videos listed in data.ts. The five GSoC 2026 selectee videos now have their links. Written quotes only appear when fewer than three videos are available. --- app/api/stories/videos/route.ts | 13 +++++++++++ components/home/data.ts | 10 ++++----- components/home/voices.tsx | 40 ++++++++++++++++++++++++--------- 3 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 app/api/stories/videos/route.ts 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/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 ? (