diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 9b9fe15..a8b3f8f 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -18,9 +18,21 @@ This guide explains how to contribute patterns, anti-patterns, or obstacles to t ```yaml --- authors: [author_id] +video: https://www.youtube.com/watch?v=VIDEO_ID&t=300s # optional --- ``` +The `video` field is optional. When present, the pattern page renders a YouTube +thumbnail with the video's title underneath. After adding or changing a `video` +URL, regenerate the cached video titles: + +```bash +cd website && npm run fetch:videos +``` + +This updates `website/lib/video-titles.json` (commit it alongside the markdown +change). + **Relationships:** Define in `documents/relationships.mmd` using Mermaid graph syntax: ```mermaid patterns/your-pattern -->|solves| obstacles/some-obstacle diff --git a/documents/anti-patterns/answer-injection.md b/documents/anti-patterns/answer-injection.md index 7dbc509..92fc9e9 100644 --- a/documents/anti-patterns/answer-injection.md +++ b/documents/anti-patterns/answer-injection.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5752s --- # Answer Injection (Anti-pattern) diff --git a/documents/anti-patterns/distracted-agent.md b/documents/anti-patterns/distracted-agent.md index 35e8142..a332aba 100644 --- a/documents/anti-patterns/distracted-agent.md +++ b/documents/anti-patterns/distracted-agent.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=836s --- # Distracted Agent (Anti-pattern) diff --git a/documents/anti-patterns/perfect-recall-fallacy.md b/documents/anti-patterns/perfect-recall-fallacy.md index e474318..a2258c2 100644 --- a/documents/anti-patterns/perfect-recall-fallacy.md +++ b/documents/anti-patterns/perfect-recall-fallacy.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3135s --- # Perfect Recall Fallacy (Anti-pattern) diff --git a/documents/anti-patterns/silent-misalignment.md b/documents/anti-patterns/silent-misalignment.md index 04ac29c..b02363d 100644 --- a/documents/anti-patterns/silent-misalignment.md +++ b/documents/anti-patterns/silent-misalignment.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5142s --- # Silent Misalignment (Anti-pattern) diff --git a/documents/anti-patterns/tell-me-a-lie.md b/documents/anti-patterns/tell-me-a-lie.md index 604e860..0b8e6f6 100644 --- a/documents/anti-patterns/tell-me-a-lie.md +++ b/documents/anti-patterns/tell-me-a-lie.md @@ -1,5 +1,6 @@ --- authors: [llewellyn_falco] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=6015s --- # Tell Me a Lie (Anti-pattern) diff --git a/documents/anti-patterns/unvalidated-leaps.md b/documents/anti-patterns/unvalidated-leaps.md index 99a9ef2..188b116 100644 --- a/documents/anti-patterns/unvalidated-leaps.md +++ b/documents/anti-patterns/unvalidated-leaps.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3345s --- # Unvalidated Leaps (Anti-pattern) diff --git a/documents/obstacles/black-box-ai.md b/documents/obstacles/black-box-ai.md index d948824..d8f1007 100644 --- a/documents/obstacles/black-box-ai.md +++ b/documents/obstacles/black-box-ai.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5055s --- # Black Box AI (Obstacle) diff --git a/documents/obstacles/cannot-learn.md b/documents/obstacles/cannot-learn.md index 4b0e4e9..9e24ece 100644 --- a/documents/obstacles/cannot-learn.md +++ b/documents/obstacles/cannot-learn.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=300s --- # Cannot Learn (Obstacle) diff --git a/documents/obstacles/compliance-bias.md b/documents/obstacles/compliance-bias.md index 403326b..66e32f2 100644 --- a/documents/obstacles/compliance-bias.md +++ b/documents/obstacles/compliance-bias.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5108s --- # Compliance Bias (Obstacle) diff --git a/documents/obstacles/context-rot.md b/documents/obstacles/context-rot.md index 5bcec8b..6f1ce0c 100644 --- a/documents/obstacles/context-rot.md +++ b/documents/obstacles/context-rot.md @@ -1,6 +1,7 @@ --- authors: [lada_kesseler, steve_kuo] synonyms: [Dementia] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=352s --- # Context Rot (Obstacle) diff --git a/documents/obstacles/degrades-under-complexity.md b/documents/obstacles/degrades-under-complexity.md index b8d9264..c8b23e6 100644 --- a/documents/obstacles/degrades-under-complexity.md +++ b/documents/obstacles/degrades-under-complexity.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3550s --- # Degrades Under Complexity (Obstacle) diff --git a/documents/obstacles/excess-verbosity.md b/documents/obstacles/excess-verbosity.md index ababec3..e8ac9e9 100644 --- a/documents/obstacles/excess-verbosity.md +++ b/documents/obstacles/excess-verbosity.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1368s --- # Excess Verbosity (Obstacle) diff --git a/documents/obstacles/hallucinations.md b/documents/obstacles/hallucinations.md index bc67da6..0826fec 100644 --- a/documents/obstacles/hallucinations.md +++ b/documents/obstacles/hallucinations.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3080s --- # Hallucinations (Obstacle) diff --git a/documents/obstacles/limited-context-window.md b/documents/obstacles/limited-context-window.md index 5eb95b1..93f266d 100644 --- a/documents/obstacles/limited-context-window.md +++ b/documents/obstacles/limited-context-window.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=775s --- # Limited Context Window (Obstacle) diff --git a/documents/obstacles/limited-focus.md b/documents/obstacles/limited-focus.md index 312d6ab..15d99df 100644 --- a/documents/obstacles/limited-focus.md +++ b/documents/obstacles/limited-focus.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=968s --- # Limited Focus (Obstacle) diff --git a/documents/obstacles/non-determinism.md b/documents/obstacles/non-determinism.md index 6dec9f6..d2e2405 100644 --- a/documents/obstacles/non-determinism.md +++ b/documents/obstacles/non-determinism.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2485s --- # Non-Determinism (Obstacle) diff --git a/documents/patterns/active-partner.md b/documents/patterns/active-partner.md index 3042fe6..e1e9563 100644 --- a/documents/patterns/active-partner.md +++ b/documents/patterns/active-partner.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5313s --- # Active Partner diff --git a/documents/patterns/approved-logs.md b/documents/patterns/approved-logs.md index 4dffa15..f775779 100644 --- a/documents/patterns/approved-logs.md +++ b/documents/patterns/approved-logs.md @@ -1,5 +1,6 @@ --- authors: [ivett_ordog] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=2547s --- # Approved Logs diff --git a/documents/patterns/approved-scenarios.md b/documents/patterns/approved-scenarios.md index 87ecf88..83764c3 100644 --- a/documents/patterns/approved-scenarios.md +++ b/documents/patterns/approved-scenarios.md @@ -1,6 +1,7 @@ --- authors: [ivett_ordog] alternative_titles: ["Approved Fixtures"] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=2250s --- # Approved Scenarios diff --git a/documents/patterns/chain-of-small-steps.md b/documents/patterns/chain-of-small-steps.md index f5c85b0..2f4d0b8 100644 --- a/documents/patterns/chain-of-small-steps.md +++ b/documents/patterns/chain-of-small-steps.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3575s --- # Chain of Small Steps diff --git a/documents/patterns/check-alignment.md b/documents/patterns/check-alignment.md index 67b6567..5ae8d0e 100644 --- a/documents/patterns/check-alignment.md +++ b/documents/patterns/check-alignment.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5448s --- # Check Alignment diff --git a/documents/patterns/constrained-tests.md b/documents/patterns/constrained-tests.md index 997d210..b00824b 100644 --- a/documents/patterns/constrained-tests.md +++ b/documents/patterns/constrained-tests.md @@ -1,5 +1,6 @@ --- authors: [ivett_ordog] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=1578s --- # Constrained Tests diff --git a/documents/patterns/context-management.md b/documents/patterns/context-management.md index 64b95e4..4d0cae3 100644 --- a/documents/patterns/context-management.md +++ b/documents/patterns/context-management.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=352s --- # Context Management diff --git a/documents/patterns/context-markers.md b/documents/patterns/context-markers.md index ab751eb..3eca4bc 100644 --- a/documents/patterns/context-markers.md +++ b/documents/patterns/context-markers.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5568s --- # Context Markers diff --git a/documents/patterns/extract-knowledge.md b/documents/patterns/extract-knowledge.md index eac9ae3..e159897 100644 --- a/documents/patterns/extract-knowledge.md +++ b/documents/patterns/extract-knowledge.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=691s --- # Extract Knowledge diff --git a/documents/patterns/focused-agent.md b/documents/patterns/focused-agent.md index cff3845..8d4059f 100644 --- a/documents/patterns/focused-agent.md +++ b/documents/patterns/focused-agent.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=975s --- # Focused Agent diff --git a/documents/patterns/ground-rules.md b/documents/patterns/ground-rules.md index 9c4dfeb..61b9bf3 100644 --- a/documents/patterns/ground-rules.md +++ b/documents/patterns/ground-rules.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=635s --- # Ground Rules diff --git a/documents/patterns/habit-hooks.md b/documents/patterns/habit-hooks.md index cd667ef..17c84c7 100644 --- a/documents/patterns/habit-hooks.md +++ b/documents/patterns/habit-hooks.md @@ -1,5 +1,6 @@ --- authors: [ivett_ordog] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=775s --- # Habit Hooks diff --git a/documents/patterns/hooks.md b/documents/patterns/hooks.md index f064c4b..856b6c5 100644 --- a/documents/patterns/hooks.md +++ b/documents/patterns/hooks.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3865s --- # Hooks diff --git a/documents/patterns/knowledge-checkpoint.md b/documents/patterns/knowledge-checkpoint.md index d2a7178..e70079f 100644 --- a/documents/patterns/knowledge-checkpoint.md +++ b/documents/patterns/knowledge-checkpoint.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2495s --- # Knowledge Checkpoint diff --git a/documents/patterns/knowledge-composition.md b/documents/patterns/knowledge-composition.md index 85ed857..26a519a 100644 --- a/documents/patterns/knowledge-composition.md +++ b/documents/patterns/knowledge-composition.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1247s --- # Knowledge Composition diff --git a/documents/patterns/knowledge-document.md b/documents/patterns/knowledge-document.md index d8cf51a..d4c02a5 100644 --- a/documents/patterns/knowledge-document.md +++ b/documents/patterns/knowledge-document.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=545s --- # Knowledge Document diff --git a/documents/patterns/noise-cancellation.md b/documents/patterns/noise-cancellation.md index 9913d36..c8bd69f 100644 --- a/documents/patterns/noise-cancellation.md +++ b/documents/patterns/noise-cancellation.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1461s --- # Noise Cancellation diff --git a/documents/patterns/offload-deterministic.md b/documents/patterns/offload-deterministic.md index 7473190..0196381 100644 --- a/documents/patterns/offload-deterministic.md +++ b/documents/patterns/offload-deterministic.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2917s --- # Offload Deterministic diff --git a/documents/patterns/parallel-implementations.md b/documents/patterns/parallel-implementations.md index 070f1b0..df6f2f5 100644 --- a/documents/patterns/parallel-implementations.md +++ b/documents/patterns/parallel-implementations.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2671s --- # Parallel Implementations diff --git a/documents/patterns/playgrounds.md b/documents/patterns/playgrounds.md index dc16c63..1461ca1 100644 --- a/documents/patterns/playgrounds.md +++ b/documents/patterns/playgrounds.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3135s --- # Playgrounds diff --git a/documents/patterns/reference-docs.md b/documents/patterns/reference-docs.md index 9470915..86814ca 100644 --- a/documents/patterns/reference-docs.md +++ b/documents/patterns/reference-docs.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1136s --- # Reference Docs diff --git a/documents/patterns/reminders.md b/documents/patterns/reminders.md index 5941d41..dd02733 100644 --- a/documents/patterns/reminders.md +++ b/documents/patterns/reminders.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=4163s --- # Reminders diff --git a/documents/patterns/reverse-direction.md b/documents/patterns/reverse-direction.md index 78f2b4b..2db8b7c 100644 --- a/documents/patterns/reverse-direction.md +++ b/documents/patterns/reverse-direction.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=6066s --- # Reverse Direction diff --git a/documents/patterns/semantic-zoom.md b/documents/patterns/semantic-zoom.md index 8ee85e7..b4dc270 100644 --- a/documents/patterns/semantic-zoom.md +++ b/documents/patterns/semantic-zoom.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1388s --- # Semantic Zoom diff --git a/documents/patterns/show-the-agent-let-it-repeat-automate.md b/documents/patterns/show-the-agent-let-it-repeat-automate.md index 2bc1f07..864cda1 100644 --- a/documents/patterns/show-the-agent-let-it-repeat-automate.md +++ b/documents/patterns/show-the-agent-let-it-repeat-automate.md @@ -1,6 +1,7 @@ --- authors: [ivett_ordog] alternative_titles: ["Show me, I will repeat-automate"] +video: https://www.youtube.com/watch?v=9dyGJ2cg8p4&t=258s --- # Show the Agent, Let it Repeat/Automate diff --git a/documents/patterns/text-native.md b/documents/patterns/text-native.md index 733488e..593011e 100644 --- a/documents/patterns/text-native.md +++ b/documents/patterns/text-native.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=6265s --- # Text Native diff --git a/scripts/fetch-video-titles.mjs b/scripts/fetch-video-titles.mjs new file mode 100644 index 0000000..a877479 --- /dev/null +++ b/scripts/fetch-video-titles.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Scans documents/**/*.md frontmatter for `video:` URLs, fetches video titles +// from YouTube's public oEmbed endpoint, and writes the result to +// website/lib/video-titles.json. The JSON maps a YouTube video id to its title. +// +// Run manually after adding or updating a video link: +// npm run fetch:videos +// +// Existing entries are preserved on network failure so the build remains +// reproducible offline. + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.join(__dirname, '..'); +const DOCS_DIR = path.join(REPO_ROOT, 'documents'); +const OUTPUT_PATH = path.join(REPO_ROOT, 'website', 'lib', 'video-titles.json'); + +function getYouTubeId(url) { + try { + const parsed = new URL(url); + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1) || null; + } + if (parsed.hostname.endsWith('youtube.com')) { + return parsed.searchParams.get('v'); + } + } catch { + return null; + } + return null; +} + +function extractVideoUrl(fileContents) { + // Match `video:` line in frontmatter, capture the URL + const match = fileContents.match(/^video:\s*(.+)$/m); + return match ? match[1].trim() : null; +} + +function collectVideoIds() { + const ids = new Set(); + const categories = ['patterns', 'anti-patterns', 'obstacles']; + for (const category of categories) { + const dir = path.join(DOCS_DIR, category); + if (!fs.existsSync(dir)) continue; + for (const filename of fs.readdirSync(dir)) { + if (!filename.endsWith('.md')) continue; + const contents = fs.readFileSync(path.join(dir, filename), 'utf-8'); + const url = extractVideoUrl(contents); + if (!url) continue; + const id = getYouTubeId(url); + if (id) ids.add(id); + } + } + return Array.from(ids); +} + +async function fetchTitle(videoId) { + // Use the canonical watch URL (no timestamp) for oEmbed lookup + const watchUrl = `https://www.youtube.com/watch?v=${videoId}`; + const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchUrl)}&format=json`; + const res = await fetch(oembedUrl); + if (!res.ok) { + throw new Error(`oEmbed ${res.status} for ${videoId}`); + } + const data = await res.json(); + return data.title; +} + +function loadExisting() { + try { + return JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf-8')); + } catch { + return {}; + } +} + +async function main() { + const ids = collectVideoIds(); + console.log(`Found ${ids.length} unique video id(s) in documents/`); + + const existing = loadExisting(); + const result = { ...existing }; + let fetched = 0; + let failed = 0; + + for (const id of ids) { + try { + const title = await fetchTitle(id); + result[id] = title; + fetched += 1; + console.log(` ${id} → ${title}`); + } catch (err) { + failed += 1; + const cached = existing[id]; + if (cached) { + console.warn(` ${id} fetch failed (${err.message}); keeping cached title: ${cached}`); + } else { + console.warn(` ${id} fetch failed (${err.message}); no cached value`); + } + } + } + + // Drop entries for video ids that are no longer referenced + for (const id of Object.keys(result)) { + if (!ids.includes(id)) { + delete result[id]; + } + } + + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2) + '\n'); + console.log(`Wrote ${Object.keys(result).length} title(s) to ${path.relative(REPO_ROOT, OUTPUT_PATH)}`); + console.log(`Fetched: ${fetched}, failed: ${failed}`); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/specs/frontmatter.md b/specs/frontmatter.md index c1c7135..8fcbd95 100644 --- a/specs/frontmatter.md +++ b/specs/frontmatter.md @@ -33,3 +33,13 @@ Optional. Array of alternate terms or names for the concept. Unlike `alternative Frontend support: display only. Parsed in `lib/markdown.ts` and displayed as "Synonyms: ..." on the detail page. No URL redirects or `generateStaticParams` changes. Added by Steve Kuo in commit `1d6176c`, moved to frontmatter in `9029de6`. + +## video + +```yaml +video: https://www.youtube.com/watch?v=abc123&t=412 +``` + +Optional. A single URL pointing to a video explaining the pattern. YouTube timestamps can be embedded directly in the URL (e.g. `&t=412` or `?t=6m52s`). + +Frontend support: parsed in `lib/markdown.ts` and rendered on the detail page as a "Watch video" link that opens in a new tab. diff --git a/website/app/[category]/[slug]/page.tsx b/website/app/[category]/[slug]/page.tsx index 5f2448d..ae345fd 100644 --- a/website/app/[category]/[slug]/page.tsx +++ b/website/app/[category]/[slug]/page.tsx @@ -8,6 +8,7 @@ import { basePath } from "@/lib/config"; import { PatternCategory } from "@/lib/types"; import Authors from "@/app/components/Authors"; import RelatedLinks from "@/app/components/RelatedLinks"; +import VideoThumbnail from "@/app/components/VideoThumbnail"; import styles from "../../pattern-detail.module.css"; interface PatternPageProps { @@ -119,27 +120,38 @@ export default async function PatternPage({ params }: PatternPageProps) {
-
- {pattern.emojiIndicator && ( -
{pattern.emojiIndicator}
- )} -
-

{pattern.title}

- {pattern.alternativeTitles && pattern.alternativeTitles.length > 0 && ( -

- Also known as: {pattern.alternativeTitles.join(', ')} -

- )} - {pattern.synonyms && pattern.synonyms.length > 0 && ( -

- Synonyms: {pattern.synonyms.join(', ')} -

+
+
+ {pattern.emojiIndicator && ( +
{pattern.emojiIndicator}
)} +
+

{pattern.title}

+ {pattern.alternativeTitles && pattern.alternativeTitles.length > 0 && ( +

+ Also known as: {pattern.alternativeTitles.join(', ')} +

+ )} + {pattern.synonyms && pattern.synonyms.length > 0 && ( +

+ Synonyms: {pattern.synonyms.join(', ')} +

+ )} +
+ + {config.label} +
- - {config.label} - + {pattern.video && ( +
+ +
+ )}
diff --git a/website/app/components/VideoThumbnail.module.css b/website/app/components/VideoThumbnail.module.css new file mode 100644 index 0000000..7b86eb6 --- /dev/null +++ b/website/app/components/VideoThumbnail.module.css @@ -0,0 +1,112 @@ +.thumbnail { + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: 240px; + text-decoration: none; + color: var(--color-text-secondary); +} + +.thumbnail:hover, +.thumbnail:focus-visible { + text-decoration: none; + color: var(--color-text-primary); +} + +.thumbnail:focus-visible { + outline: none; +} + +.imageWrapper { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: var(--border-radius); + overflow: hidden; + background-color: var(--color-surface); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + transition: transform var(--transition-fast), + box-shadow var(--transition-fast); +} + +.thumbnail:hover .imageWrapper, +.thumbnail:focus-visible .imageWrapper { + transform: translateY(-2px); + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); +} + +.thumbnail:focus-visible .imageWrapper { + outline: 2px solid var(--color-accent); + outline-offset: 3px; +} + +.image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + 135deg, + rgba(0, 0, 0, 0.05) 0%, + rgba(0, 0, 0, 0.35) 100% + ); + transition: background var(--transition-fast); +} + +.thumbnail:hover .overlay, +.thumbnail:focus-visible .overlay { + background: linear-gradient( + 135deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.45) 100% + ); +} + +.playIcon { + width: 46px; + height: 46px; + padding: 10px 10px 10px 12px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.92); + color: #111; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + transition: transform var(--transition-fast), + background-color var(--transition-fast); +} + +.thumbnail:hover .playIcon, +.thumbnail:focus-visible .playIcon { + transform: scale(1.08); + background-color: #fff; +} + +.caption { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: var(--font-size-xs); + line-height: 1.35; + font-weight: 500; +} + +@media (max-width: 768px) { + .thumbnail { + width: 180px; + } + + .playIcon { + width: 38px; + height: 38px; + padding: 8px 8px 8px 10px; + } +} diff --git a/website/app/components/VideoThumbnail.tsx b/website/app/components/VideoThumbnail.tsx new file mode 100644 index 0000000..2607dbf --- /dev/null +++ b/website/app/components/VideoThumbnail.tsx @@ -0,0 +1,50 @@ +import { getYouTubeId } from '@/lib/video-titles'; +import styles from './VideoThumbnail.module.css'; + +interface VideoThumbnailProps { + url: string; + title: string; + videoTitle?: string; +} + +export default function VideoThumbnail({ url, title, videoTitle }: VideoThumbnailProps) { + const videoId = getYouTubeId(url); + if (!videoId) return null; + + const thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; + const ariaLabel = videoTitle + ? `Watch video: ${videoTitle}` + : `Watch video: ${title}`; + + return ( + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + + {videoTitle && {videoTitle}} + + ); +} diff --git a/website/app/pattern-catalog/CatalogView.tsx b/website/app/pattern-catalog/CatalogView.tsx index e836aea..e5f6b6a 100644 --- a/website/app/pattern-catalog/CatalogView.tsx +++ b/website/app/pattern-catalog/CatalogView.tsx @@ -10,6 +10,7 @@ import { CatalogGroupData, CatalogPreviewItem } from "./types"; import { COMPLETE_CATALOG_TEST_IDS } from "./test-ids"; import { getCategoryConfig } from "@/app/lib/category-config"; import SearchBar from "@/app/components/SearchBar"; +import VideoThumbnail from "@/app/components/VideoThumbnail"; import { PatternContent } from "@/lib/types"; interface CatalogViewProps { @@ -414,18 +415,39 @@ export default function CatalogView({ groups, title }: CatalogViewProps) { return (
-
- {selected.item.emojiIndicator && ( - - )} -

{selected.item.title}

+
+
+ {selected.item.emojiIndicator && ( + + )} +
+

{selected.item.title}

+ {selected.item.alternativeTitles && selected.item.alternativeTitles.length > 0 && ( +

+ Also known as: {selected.item.alternativeTitles.join(', ')} +

+ )} + {selected.item.synonyms && selected.item.synonyms.length > 0 && ( +

+ Synonyms: {selected.item.synonyms.join(', ')} +

+ )} +
+
+ + {selectedConfig.icon} + {selectedConfig.label} +
- - {selectedConfig.icon} - {selectedConfig.label} - + {selected.item.video && ( + + )}
diff --git a/website/app/pattern-catalog/page.module.css b/website/app/pattern-catalog/page.module.css index 9c30fb7..d837cd0 100644 --- a/website/app/pattern-catalog/page.module.css +++ b/website/app/pattern-catalog/page.module.css @@ -706,6 +706,13 @@ padding-right: var(--space-sm); } +.detailTitleBlock { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + .detailTitle { font-size: var(--font-size-3xl); color: var(--color-text-primary); @@ -713,6 +720,12 @@ font-weight: 600; } +.detailMeta { + margin: 0; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + .detailBody { font-size: var(--font-size-base); color: var(--color-text-primary); @@ -892,13 +905,23 @@ .detailHeader { display: flex; - flex-direction: column; - gap: var(--space-md); + align-items: flex-start; + justify-content: space-between; + gap: var(--space-xl); padding-bottom: var(--space-md); border-bottom: 1px solid var(--color-border); margin-bottom: var(--space-2xl); } +.detailHeaderMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-md); +} + .detailTitleRow { display: flex; align-items: flex-start; diff --git a/website/app/pattern-catalog/page.tsx b/website/app/pattern-catalog/page.tsx index 64d6dbb..44d25b1 100644 --- a/website/app/pattern-catalog/page.tsx +++ b/website/app/pattern-catalog/page.tsx @@ -83,11 +83,15 @@ function buildCatalogGroups(): CatalogGroupData[] { slug: pattern.slug, title: pattern.title, emojiIndicator: pattern.emojiIndicator, + alternativeTitles: pattern.alternativeTitles, + synonyms: pattern.synonyms, authorIds: pattern.authors ?? [], authorNames: resolveAuthorNames(pattern.authors), authorGithubs: pattern.authors?.map(resolveAuthorGithub).filter((g): g is string => g !== null) ?? [], summary: extractSummary(pattern.content), content: pattern.content, + video: pattern.video, + videoTitle: pattern.videoTitle, }) satisfies CatalogPreviewItem ); diff --git a/website/app/pattern-catalog/types.ts b/website/app/pattern-catalog/types.ts index 23f7107..eac03ea 100644 --- a/website/app/pattern-catalog/types.ts +++ b/website/app/pattern-catalog/types.ts @@ -4,11 +4,15 @@ export interface CatalogPreviewItem { slug: string; title: string; emojiIndicator?: string; + alternativeTitles?: string[]; + synonyms?: string[]; authorIds: string[]; authorNames: string[]; authorGithubs: string[]; summary?: string; content: string; + video?: string; + videoTitle?: string; } export interface CatalogGroupData { diff --git a/website/app/pattern-detail.module.css b/website/app/pattern-detail.module.css index 1398e97..301b646 100644 --- a/website/app/pattern-detail.module.css +++ b/website/app/pattern-detail.module.css @@ -24,16 +24,32 @@ } .header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-xl); margin-bottom: var(--space-2xl); padding-bottom: var(--space-xl); border-bottom: 1px solid var(--color-border); } +.headerMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-md); +} + +.headerAside { + flex-shrink: 0; +} + .titleWrapper { display: flex; align-items: flex-start; gap: var(--space-lg); - margin-bottom: var(--space-md); } .emoji { @@ -199,6 +215,15 @@ padding: var(--space-xl) var(--space-lg); } + .header { + flex-direction: column; + gap: var(--space-md); + } + + .headerAside { + align-self: flex-start; + } + .titleWrapper { flex-direction: column; gap: var(--space-md); diff --git a/website/lib/markdown.ts b/website/lib/markdown.ts index b3876e2..0362bf7 100644 --- a/website/lib/markdown.ts +++ b/website/lib/markdown.ts @@ -3,6 +3,7 @@ import path from 'path' import matter from 'gray-matter' import { PatternCategory, PatternContent, RelationshipType } from './types' import { getRelationshipsForBoth } from './relationships' +import { getVideoTitle } from './video-titles' const PATTERNS_BASE_PATH = path.join(process.cwd(), '..', 'documents') @@ -179,6 +180,8 @@ export function getPatternBySlug( ...(data.authors && { authors: data.authors }), ...(data.alternative_titles && { alternativeTitles: data.alternative_titles }), ...(data.synonyms && { synonyms: data.synonyms }), + ...(data.video && { video: data.video }), + ...(data.video && getVideoTitle(data.video) && { videoTitle: getVideoTitle(data.video) }), ...(mergedPatterns.length > 0 && { relatedPatterns: mergedPatterns }), ...(mergedAntiPatterns.length > 0 && { relatedAntiPatterns: mergedAntiPatterns }), ...(mergedObstacles.length > 0 && { relatedObstacles: mergedObstacles }), diff --git a/website/lib/types.ts b/website/lib/types.ts index f38028d..8280ac1 100644 --- a/website/lib/types.ts +++ b/website/lib/types.ts @@ -20,6 +20,8 @@ export interface PatternMetadata { authors?: string[] alternativeTitles?: string[] synonyms?: string[] + video?: string + videoTitle?: string relatedPatterns?: RelatedPattern[] relatedAntiPatterns?: RelatedPattern[] relatedObstacles?: RelatedPattern[] diff --git a/website/lib/video-titles.json b/website/lib/video-titles.json new file mode 100644 index 0000000..0436767 --- /dev/null +++ b/website/lib/video-titles.json @@ -0,0 +1,5 @@ +{ + "_LSK2bVf0Lc": "AI Talks - Lada Kesseler: Augmented Coding: Mapping the Uncharted Territory", + "GyI5qU9MNJU": "Ivett Ördög - Managing Cognitive Load in the Age of AI", + "9dyGJ2cg8p4": "How I Cut My Database Costs by 60% in 5 Days With AI" +} diff --git a/website/lib/video-titles.ts b/website/lib/video-titles.ts new file mode 100644 index 0000000..2b5b562 --- /dev/null +++ b/website/lib/video-titles.ts @@ -0,0 +1,24 @@ +import videoTitlesJson from './video-titles.json' + +const videoTitles: Record = videoTitlesJson + +export function getYouTubeId(url: string): string | null { + try { + const parsed = new URL(url) + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1) || null + } + if (parsed.hostname.endsWith('youtube.com')) { + return parsed.searchParams.get('v') + } + return null + } catch { + return null + } +} + +export function getVideoTitle(videoUrl: string): string | undefined { + const id = getYouTubeId(videoUrl) + if (!id) return undefined + return videoTitles[id] +} diff --git a/website/package.json b/website/package.json index edcbfe7..17df043 100644 --- a/website/package.json +++ b/website/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev --turbopack", "validate": "node ../scripts/validate-relationships.js", + "fetch:videos": "node ../scripts/fetch-video-titles.mjs", "build": "npm run validate && next build", "start": "next start", "lint": "eslint", diff --git a/website/tests/unit/completeCatalog.test.tsx b/website/tests/unit/completeCatalog.test.tsx index cc129e5..6cff5d0 100644 --- a/website/tests/unit/completeCatalog.test.tsx +++ b/website/tests/unit/completeCatalog.test.tsx @@ -173,7 +173,9 @@ describe('PatternCatalogPage', () => { ).toBeInTheDocument() expect(within(detailPane).getByText(/Description/i)).toBeInTheDocument() expect(within(detailPane).getByText(/Documented by/i)).toBeInTheDocument() - expect(within(detailPane).getByText(/Lada Kesseler/i)).toBeInTheDocument() + expect( + within(detailPane).getByRole('link', { name: /Lada Kesseler's avatar Lada Kesseler/i }) + ).toBeInTheDocument() expect(within(detailPane).queryByText(/Open full entry/i)).not.toBeInTheDocument() }) diff --git a/website/tests/unit/components/pages.test.tsx b/website/tests/unit/components/pages.test.tsx index bf5237e..9bbc4d9 100644 --- a/website/tests/unit/components/pages.test.tsx +++ b/website/tests/unit/components/pages.test.tsx @@ -471,6 +471,40 @@ describe('Pattern Detail Page', () => { }) }) + describe('Video Display', () => { + it('displays a video thumbnail link when video is present', async () => { + const videoUrl = 'https://www.youtube.com/watch?v=abc123&t=412' + const patternWithVideo = { + ...mockPattern, + video: videoUrl + } + mockGetPatternBySlug.mockReturnValue(patternWithVideo) + + const params = Promise.resolve({ category: 'patterns', slug: 'test-pattern-detail' }) + render(await PatternPage({ params })) + + const link = screen.getByRole('link', { name: /Watch video/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', videoUrl) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + + const thumbnail = link.querySelector('img') + expect(thumbnail).toBeInTheDocument() + expect(thumbnail).toHaveAttribute( + 'src', + 'https://i.ytimg.com/vi/abc123/mqdefault.jpg' + ) + }) + + it('does not display video thumbnail when video is absent', async () => { + const params = Promise.resolve({ category: 'patterns', slug: 'test-pattern-detail' }) + render(await PatternPage({ params })) + + expect(screen.queryByRole('link', { name: /Watch video/i })).not.toBeInTheDocument() + }) + }) + describe('Pattern without emoji', () => { it('renders correctly when pattern has no emoji', async () => { const patternWithoutEmoji = { ...mockPattern, emojiIndicator: undefined } diff --git a/website/tests/unit/markdown.test.ts b/website/tests/unit/markdown.test.ts index 9160864..0743c61 100644 --- a/website/tests/unit/markdown.test.ts +++ b/website/tests/unit/markdown.test.ts @@ -453,6 +453,42 @@ AI defaults to silent compliance.` expect(pattern).toBeDefined() expect(pattern.alternativeTitles).toEqual(["Old Name"]) }) + + it('should extract video from frontmatter', () => { + const mockMarkdown = `--- +video: https://www.youtube.com/watch?v=abc123&t=412 +--- +# Active Partner + +## Problem +AI defaults to silent compliance.` + + mockedPath.join.mockReturnValue('/fake/path/documents/patterns/active-partner.md') + mockedFs.readFileSync.mockReturnValue(mockMarkdown) + + const pattern = getPatternBySlug('patterns', 'active-partner') + + expect(pattern).toBeDefined() + expect(pattern.video).toBe('https://www.youtube.com/watch?v=abc123&t=412') + }) + + it('should handle pattern without video', () => { + const mockMarkdown = `--- +authors: [lexler] +--- +# Active Partner + +## Problem +AI defaults to silent compliance.` + + mockedPath.join.mockReturnValue('/fake/path/documents/patterns/active-partner.md') + mockedFs.readFileSync.mockReturnValue(mockMarkdown) + + const pattern = getPatternBySlug('patterns', 'active-partner') + + expect(pattern).toBeDefined() + expect(pattern.video).toBeUndefined() + }) }) describe('getAllPatterns', () => {