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
4 changes: 4 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,10 @@
border-color: rgba(62, 127, 92, 0.4);
box-shadow: var(--home-shadow-md);
}
.media-card-featured {
border-color: rgba(62, 127, 92, 0.45);
box-shadow: 0 12px 30px rgba(62, 127, 92, 0.1), var(--home-shadow-sm);
}
.home-root a:not(.home-btn):not(.home-arrow-link) { text-decoration-color: var(--home-fern); }
.home-root section, .home-root footer > div > div { border-color: var(--home-hairline); }
/* Buttons had 10px of horizontal padding against 8px vertical, so the label
Expand Down
24 changes: 23 additions & 1 deletion src/app/media/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,30 @@ export default async function MediaPage() {
<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."
/>
>
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--home-hairline-strong)] bg-[var(--home-white)]/80 px-3.5 py-2 text-sm text-[var(--home-ink-soft)] shadow-[var(--home-shadow-sm)]">
<span
aria-hidden="true"
className="h-2 w-2 rounded-full bg-[var(--home-fern)]"
/>
{items.length === 0
? "The first videos are on the way"
: `${items.length} ${items.length === 1 ? "video" : "videos"} to explore`}
</div>
</PageHero>
<PageSection>
<div className="mb-8 flex flex-col gap-4 border-b border-[var(--home-hairline)] pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="home-serif text-[1.75rem] md:text-[2.25rem]">The collection</h2>
<p className="mt-2 max-w-2xl text-[var(--home-ink-soft)]">
Start with a lesson, stay for a student story, or see what we&apos;re
building next.
</p>
</div>
<p className="text-sm text-[var(--home-ink-quiet)]">
{items.length === 0 ? "No videos published yet" : "Newest first"}
</p>
</div>
<MediaLibrary items={items} />
</PageSection>
</PageShell>
Expand Down
61 changes: 46 additions & 15 deletions src/components/media/MediaCard.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { ArrowUpRight } from "lucide-react";
import { InstagramIcon } from "@/components/icons";
import type { MediaItem } from "@/lib/media";
import { getYouTubeEmbedUrl } from "@/lib/media";
import { formatMediaDate, getYouTubeEmbedUrl } from "@/lib/media";

interface MediaCardProps {
item: MediaItem;
emphasis?: boolean;
}

export function MediaCard({ item }: MediaCardProps) {
export function MediaCard({ item, emphasis = false }: MediaCardProps) {
const publishedDate = formatMediaDate(item.publishedAt);
const cardClassName = `home-card home-lift overflow-hidden rounded-[20px] ${
emphasis ? "media-card-featured" : ""
}`;

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)]">
<article className={cardClassName}>
<div className="relative aspect-video bg-[var(--home-grey-450)]">
<iframe
src={getYouTubeEmbedUrl(item.videoId)}
title={`${item.title} on YouTube`}
Expand All @@ -25,24 +32,36 @@ export function MediaCard({ item }: MediaCardProps) {
/>
</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>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[11px] font-medium uppercase tracking-[0.1em] text-[var(--home-ink-quiet)]">
<span>YouTube</span>
{publishedDate && (
<time dateTime={item.publishedAt}>{publishedDate}</time>
)}
</div>
<h2 className="mt-3 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>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${item.title} — watch on YouTube`}
className="home-arrow-link mt-5"
>
Watch on YouTube <ArrowUpRight className="h-4 w-4" />
</a>
</div>
</article>
);
}

return (
<article className="home-card home-lift overflow-hidden rounded-[20px]">
<article className={cardClassName}>
<a
href={item.url}
target="_blank"
rel="noreferrer"
rel="noopener noreferrer"
aria-label={`${item.title} — watch on Instagram`}
className="group block"
>
Expand All @@ -68,12 +87,18 @@ export function MediaCard({ item }: MediaCardProps) {
</span>
</div>
<div className="p-6 md:p-7">
<h2 className="text-xl leading-tight md:text-2xl">{item.title}</h2>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[11px] font-medium uppercase tracking-[0.1em] text-[var(--home-ink-quiet)]">
<span>Instagram</span>
{publishedDate && (
<time dateTime={item.publishedAt}>{publishedDate}</time>
)}
</div>
<h2 className="mt-3 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>
Watch on Instagram <ArrowUpRight className="h-4 w-4" />
</span>
</div>
</a>
Expand All @@ -83,10 +108,12 @@ export function MediaCard({ item }: MediaCardProps) {

export function MediaGrid({
items,
layout = "preview",
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[];
layout?: "preview" | "library";
emptyTitle?: string;
emptyDescription?: string;
}) {
Expand All @@ -102,9 +129,13 @@ export function MediaGrid({
}

return (
<div className="grid gap-6 md:grid-cols-2">
{items.map((item) => (
<MediaCard key={item.id} item={item} />
<div className="grid gap-5 md:grid-cols-2 md:gap-6 xl:grid-cols-3">
{items.map((item, index) => (
<MediaCard
key={item.id}
item={item}
emphasis={layout === "library" && index === 0}
/>
))}
</div>
);
Expand Down
73 changes: 45 additions & 28 deletions src/components/media/MediaLibrary.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
"use client";

import { useMemo, useState } from "react";
import { useState } from "react";
import { Search } from "lucide-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" },
{ label: "Instagram", value: "instagram" },
];

function matchesSearch(item: MediaItem, search: string): boolean {
Expand All @@ -23,35 +23,38 @@ function matchesSearch(item: MediaItem, search: string): boolean {
export function MediaLibrary({ items }: { items: readonly MediaItem[] }) {
const [filter, setFilter] = useState<MediaFilter>("all");
const [search, setSearch] = useState("");
const availableFilters = FILTERS.filter(
(option) => option.value === "all" || items.some((item) => item.platform === option.value),
);

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

const hasActiveFilters = filter !== "all" || search.trim().length > 0;
const resultLabel = filteredItems.length === 1 ? "video" : "videos";
const resultLabel = items.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 className="text-sm font-medium text-[var(--home-ink)]">Find a video</p>
<p className="mt-1 text-sm text-[var(--home-ink-soft)]">
Filter 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) => {
{availableFilters.map((option) => {
const isActive = filter === option.value;
const count =
option.value === "all"
? items.length
: items.filter((item) => item.platform === option.value).length;

return (
<button
Expand All @@ -64,33 +67,46 @@ export function MediaLibrary({ items }: { items: readonly MediaItem[] }) {
onClick={() => setFilter(option.value)}
>
{option.label}
<span
aria-hidden="true"
className={`rounded-full px-1.5 py-0.5 text-[11px] ${
isActive
? "bg-white/15 text-white"
: "bg-[var(--home-grey-400)] text-[var(--home-ink-soft)]"
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>

<div className="w-full lg:max-w-xs">
<label
htmlFor="media-search"
className="text-sm font-medium text-[var(--home-ink)]"
>
<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 className="relative mt-2">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--home-ink-quiet)]"
/>
<input
id="media-search"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Try “students”"
className="min-h-11 w-full rounded-lg border border-[var(--home-hairline-strong)] bg-[var(--home-page)] py-2.5 pl-10 pr-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>

<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}
Showing {filteredItems.length} of {items.length} {resultLabel}
</p>
{hasActiveFilters && (
<button
Expand All @@ -110,6 +126,7 @@ export function MediaLibrary({ items }: { items: readonly MediaItem[] }) {
<div className="mt-8">
<MediaGrid
items={filteredItems}
layout="library"
emptyTitle={items.length === 0 ? undefined : "No videos match those filters"}
emptyDescription={
items.length === 0
Expand Down
28 changes: 28 additions & 0 deletions src/lib/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ export interface MediaItem {
export const MEDIA_ITEMS: readonly MediaItem[] = [];

const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
const MEDIA_MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const;

function isYouTubeUrl(value: string, videoId: string): boolean {
try {
Expand Down Expand Up @@ -165,3 +179,17 @@ export function getYouTubeEmbedUrl(videoId: string): string {
}
return `https://www.youtube-nocookie.com/embed/${videoId}`;
}

/** Formats an ISO date without depending on the visitor's timezone or locale. */
export function formatMediaDate(value?: string): string | null {
const match = value?.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) return null;

const [, year, month, day] = match;
const monthIndex = Number(month) - 1;
if (!MEDIA_MONTHS[monthIndex] || Number(day) < 1 || Number(day) > 31) {
return null;
}

return `${Number(day)} ${MEDIA_MONTHS[monthIndex]} ${year}`;
}
Loading