Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/api/stories/videos/route.ts
Original file line number Diff line number Diff line change
@@ -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' },
});
}
51 changes: 6 additions & 45 deletions app/testimonials/page.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,25 @@
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';
import { Card, Eyebrow, PageHero, Panel, Section } from '@/components/site/page-kit';

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',
description: 'Hear from our community members about their experience with DevWeekends.',
};

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 (
<>
Expand Down
10 changes: 5 additions & 5 deletions components/home/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
40 changes: 29 additions & 11 deletions components/home/voices.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const [playing, setPlaying] = useState<string | null>(null)
const [live, setLive] = useState<LiveVideo[]>([])

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<string>()
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
Expand Down Expand Up @@ -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) => (
<article key={video.name} className="w-[360px] shrink-0 snap-start overflow-hidden rounded-[20px] border border-[#E6E6E6]">
<article key={video.embed.embedUrl} className="w-[360px] shrink-0 snap-start overflow-hidden rounded-[20px] border border-[#E6E6E6]">
<div className="relative aspect-video bg-[#0A0A0A]">
{playing === video.name ? (
{playing === video.embed.embedUrl ? (
<iframe
src={video.embed.embedUrl}
title={`${video.name}: video story`}
Expand All @@ -77,14 +95,14 @@ export function VoicesRail() {
) : (
<button
type="button"
onClick={() => setPlaying(video.name)}
onClick={() => setPlaying(video.embed.embedUrl)}
aria-label={`Play video: ${video.name}`}
className="group absolute inset-0 flex items-center justify-center"
>
{video.embed.thumbnailUrl && (
{video.preview && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={video.embed.thumbnailUrl}
src={video.preview}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover opacity-80 grayscale transition group-hover:opacity-100 group-hover:grayscale-0"
Expand All @@ -104,7 +122,7 @@ export function VoicesRail() {
</article>
))}

{voices.map((voice) => (
{quotes.map((voice) => (
<article key={voice.name} className="flex min-h-[320px] w-[360px] shrink-0 snap-start flex-col rounded-[20px] border border-[#E6E6E6] p-7 transition-shadow duration-200 hover:shadow-[0_18px_36px_-24px_rgba(0,0,0,0.3)]">
<p className="text-[17px] leading-[1.6] text-[#262626]">&ldquo;{voice.quote}&rdquo;</p>
<div className="mt-auto border-t border-[#E6E6E6] pt-[18px]">
Expand Down
4 changes: 3 additions & 1 deletion components/testimonial-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down
101 changes: 101 additions & 0 deletions lib/stories.ts
Original file line number Diff line number Diff line change
@@ -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<Testimonial[]> {
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<Testimonial[]> {
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<Testimonial[]> {
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<string | undefined> {
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<Testimonial[]> {
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<VideoStory[]> {
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));
}
1 change: 1 addition & 0 deletions lib/video-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading