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
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,29 @@ RESEND_AUDIENCE_ID=
# routed to team@codewithpurpose.org, which is a real Google Workspace inbox.
EMAIL_FROM="CodeWithPurpose <hello@codewithpurpose.org>"

# --- YouTube Data API (media library auto-pull) -----------------------------
# Powers the YouTube side of /media and the homepage "See what we're making"
# section: the channel's recent uploads are fetched and refreshed hourly, so
# a new video appears on the site on its own — no code change, no redeploy.
#
# Google Cloud Console -> APIs & Services -> Credentials -> Create API key,
# with the "YouTube Data API v3" enabled on that project. Restrict the key to
# that API only. Server-only — no NEXT_PUBLIC_ prefix, so it is never bundled
# into the browser.
#
# Leave these blank and nothing breaks: the library falls back to whatever is
# hand-curated in lib/media.ts (or the "new videos are on the way" empty state
# if that's empty too).
YOUTUBE_API_KEY=

# The channel's @handle (recommended, e.g. "codewithpurpose") or, failing
# that, its UC... channel ID via YOUTUBE_CHANNEL_ID instead. Only one is
# needed.
YOUTUBE_CHANNEL_HANDLE=
YOUTUBE_CHANNEL_ID=

# Instagram has no equivalent here: its official Graph API needs a
# Business/Creator account linked to a Facebook Page and a long-lived access
# token that expires roughly every 60 days, so it isn't wired up as
# auto-pull yet. Add Instagram entries by hand in lib/media.ts for now.

28 changes: 28 additions & 0 deletions src/app/media/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { MediaLibrary } from "@/components/media/MediaLibrary";
import { PageHero, PageSection } from "@/components/PageHero";
import { PageShell } from "@/components/PageShell";
import { getMediaItems } from "@/lib/media";

export const metadata: Metadata = {
title: "Media",
description:
"Short lessons, student stories, and behind-the-scenes videos from CodeWithPurpose.",
alternates: { canonical: "/media/" },
};

export default async function MediaPage() {
const items = await getMediaItems();

return (
<PageShell>
<PageHero
title="What we&apos;re making, in motion"
description="Short lessons, student stories, and the work happening behind free coding education. Watch on the site or follow the original post to join the conversation."
/>
<PageSection>
<MediaLibrary items={items} />
</PageSection>
</PageShell>
);
}
2 changes: 2 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { GlobalReachSection } from "@/components/GlobalReachSection";
import { SecuritySection } from "@/components/SecuritySection";
import { QuoteSection } from "@/components/QuoteSection";
import { HowItWorksSection } from "@/components/HowItWorksSection";
import { MediaSection } from "@/components/media/MediaSection";
import { FaqSection } from "@/components/FaqSection";
import { FinalCtaSection } from "@/components/FinalCtaSection";
import { SiteFooter } from "@/components/SiteFooter";
Expand Down Expand Up @@ -40,6 +41,7 @@ export default function Home() {
</div>
<QuoteSection />
<HowItWorksSection />
<MediaSection />
<FaqSection />
<FinalCtaSection />
</main>
Expand Down
1 change: 1 addition & 0 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
"/impact",
"/contact",
"/donate",
"/media",
"/learn/ml",
"/learn/vibecoding",
"/learn/python",
Expand Down
6 changes: 5 additions & 1 deletion src/components/PageHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export function PageHero({
return (
<section className="relative overflow-hidden border-b-[0.5px] border-[var(--home-hairline)] bg-[var(--home-page)] pt-12 pb-14 md:pt-20 md:pb-24">
<div aria-hidden="true" className="cwp-hero-bg absolute inset-0" />
<div className="relative mx-auto grid w-full max-w-[85rem] items-center gap-10 px-5 md:px-10 lg:grid-cols-2">
<div
className={`relative mx-auto grid w-full max-w-[85rem] items-center gap-10 px-5 md:px-10 ${
image ? "lg:grid-cols-2" : "lg:grid-cols-1"
}`}
>
<Reveal>
<h1 className="home-display text-[2rem] leading-[1.05] tracking-[-0.02em] md:text-[2.75rem] lg:text-[3.25rem]">
{title}
Expand Down
6 changes: 6 additions & 0 deletions src/components/SiteFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
LEARN_ML_HREF,
LEARN_PYTHON_HREF,
LEARN_VIBECODING_HREF,
MEDIA_HREF,
TOOLKIT_HREF,
X_HREF,
} from "@/lib/links";
Expand Down Expand Up @@ -106,6 +107,11 @@ export function SiteFooter() {
Learning Toolkit
</a>
</li>
<li>
<a href={MEDIA_HREF} className="home-footer-link">
Media
</a>
</li>
<li>
<a href={ABOUT_HREF} className="home-footer-link">
Our Story
Expand Down
111 changes: 111 additions & 0 deletions src/components/media/MediaCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { InstagramIcon } from "@/components/icons";
import type { MediaItem } from "@/lib/media";
import { getYouTubeEmbedUrl } from "@/lib/media";

interface MediaCardProps {
item: MediaItem;
}

export function MediaCard({ item }: MediaCardProps) {
if (item.platform === "youtube") {
if (!item.videoId) {
throw new Error(`YouTube item ${item.id} is missing its videoId.`);
}

return (
<article className="home-card overflow-hidden rounded-[20px]">
<div className="aspect-video bg-[var(--home-grey-450)]">
<iframe
src={getYouTubeEmbedUrl(item.videoId)}
title={`${item.title} on YouTube`}
loading="lazy"
allow="encrypted-media; picture-in-picture"
allowFullScreen
className="h-full w-full border-0"
/>
</div>
<div className="p-6 md:p-7">
<p className="text-[11px] uppercase tracking-[0.12em] text-[var(--home-ink-quiet)]">
YouTube
</p>
<h2 className="mt-2 text-xl leading-tight md:text-2xl">{item.title}</h2>
<p className="mt-3 text-[15px] leading-[1.55] text-[var(--home-ink-soft)]">
{item.description}
</p>
</div>
</article>
);
}

return (
<article className="home-card home-lift overflow-hidden rounded-[20px]">
<a
href={item.url}
target="_blank"
rel="noreferrer"
aria-label={`${item.title} — watch on Instagram`}
className="group block"
>
<div className="relative aspect-video overflow-hidden bg-[linear-gradient(135deg,#f4d7d2,#e1d7f3_52%,#d7eedb)]">
{item.thumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.thumbnail}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.025]"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[var(--home-moss)]">
<InstagramIcon className="h-12 w-12" />
<span className="text-sm font-medium">Watch on Instagram</span>
</div>
)}
<span className="absolute bottom-4 left-4 inline-flex items-center gap-2 rounded-full bg-[var(--home-ink)] px-3 py-1.5 text-xs font-medium text-white shadow-[var(--home-shadow-sm)]">
<InstagramIcon className="h-3.5 w-3.5" />
Instagram
</span>
</div>
<div className="p-6 md:p-7">
<h2 className="text-xl leading-tight md:text-2xl">{item.title}</h2>
<p className="mt-3 text-[15px] leading-[1.55] text-[var(--home-ink-soft)]">
{item.description}
</p>
<span className="home-arrow-link mt-5">
Watch on Instagram <span className="home-arrow">→</span>
</span>
</div>
</a>
</article>
);
}

export function MediaGrid({
items,
emptyTitle = "New videos are on the way",
emptyDescription = "We're collecting the lessons, workshops, and small moments that make CodeWithPurpose what it is. Check back soon for the first set.",
}: {
items: readonly MediaItem[];
emptyTitle?: string;
emptyDescription?: string;
}) {
if (items.length === 0) {
return (
<div className="home-card rounded-[20px] bg-[var(--home-grey-450)] p-8 text-center md:p-12">
<h2 className="home-serif text-[1.75rem] md:text-[2.25rem]">{emptyTitle}</h2>
<p className="mx-auto mt-3 max-w-xl text-[15px] leading-[1.6] text-[var(--home-ink-soft)]">
{emptyDescription}
</p>
</div>
);
}

return (
<div className="grid gap-6 md:grid-cols-2">
{items.map((item) => (
<MediaCard key={item.id} item={item} />
))}
</div>
);
}
123 changes: 123 additions & 0 deletions src/components/media/MediaLibrary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"use client";

import { useMemo, useState } from "react";
import { MediaGrid } from "@/components/media/MediaCard";
import type { MediaItem, MediaPlatform } from "@/lib/media";

type MediaFilter = "all" | MediaPlatform;

// Instagram tab removed until Instagram entries exist in lib/media.ts — add
// `{ label: "Instagram", value: "instagram" }` back once they do.
const FILTERS: readonly { label: string; value: MediaFilter }[] = [
{ label: "All videos", value: "all" },
{ label: "YouTube", value: "youtube" },
];

function matchesSearch(item: MediaItem, search: string): boolean {
if (!search.trim()) return true;

const query = search.trim().toLowerCase();
return `${item.title} ${item.description}`.toLowerCase().includes(query);
}

export function MediaLibrary({ items }: { items: readonly MediaItem[] }) {
const [filter, setFilter] = useState<MediaFilter>("all");
const [search, setSearch] = useState("");

const filteredItems = useMemo(
() =>
items.filter(
(item) =>
(filter === "all" || item.platform === filter) &&
matchesSearch(item, search),
),
[filter, items, search],
);

const hasActiveFilters = filter !== "all" || search.trim().length > 0;
const resultLabel = filteredItems.length === 1 ? "video" : "videos";

return (
<div>
<div className="home-card rounded-[20px] p-5 md:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm text-[var(--home-ink-soft)]">
Browse the library by platform or search the collection.
</p>
<div
aria-label="Filter media by platform"
className="mt-4 flex flex-wrap gap-2"
role="group"
>
{FILTERS.map((option) => {
const isActive = filter === option.value;

return (
<button
key={option.value}
type="button"
aria-pressed={isActive}
className={`home-btn ${
isActive ? "home-btn-fill" : "home-btn-outline"
}`}
onClick={() => setFilter(option.value)}
>
{option.label}
</button>
);
})}
</div>
</div>

<div className="w-full lg:max-w-xs">
<label
htmlFor="media-search"
className="text-sm font-medium text-[var(--home-ink)]"
>
Search videos
</label>
<input
id="media-search"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Try “students”"
className="mt-2 min-h-11 w-full rounded-lg border border-[var(--home-hairline-strong)] bg-[var(--home-page)] px-3.5 text-[15px] text-[var(--home-ink)] outline-none transition-shadow placeholder:text-[var(--home-ink-quiet)] focus-visible:ring-2 focus-visible:ring-[var(--home-fern)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--home-white)]"
/>
</div>
</div>

<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--home-hairline)] pt-4 text-sm text-[var(--home-ink-soft)]">
<p aria-live="polite">
Showing {filteredItems.length} {resultLabel}
</p>
{hasActiveFilters && (
<button
type="button"
className="home-arrow-link text-sm"
onClick={() => {
setFilter("all");
setSearch("");
}}
>
Clear filters
</button>
)}
</div>
</div>

<div className="mt-8">
<MediaGrid
items={filteredItems}
emptyTitle={items.length === 0 ? undefined : "No videos match those filters"}
emptyDescription={
items.length === 0
? undefined
: "Try a different platform or search term, or clear the filters to see the full library."
}
/>
</div>
</div>
);
}
Loading
Loading