-
- {selected.item.emojiIndicator && (
-
- {selected.item.emojiIndicator}
-
- )}
-
{selected.item.title}
+
+
+ {selected.item.emojiIndicator && (
+
+ {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 a1e94c2..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')
@@ -180,6 +181,7 @@ export function getPatternBySlug(
...(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 66df8bb..8280ac1 100644
--- a/website/lib/types.ts
+++ b/website/lib/types.ts
@@ -21,6 +21,7 @@ export interface PatternMetadata {
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 9306744..9bbc4d9 100644
--- a/website/tests/unit/components/pages.test.tsx
+++ b/website/tests/unit/components/pages.test.tsx
@@ -472,7 +472,7 @@ describe('Pattern Detail Page', () => {
})
describe('Video Display', () => {
- it('displays a Watch video link when video is present', async () => {
+ 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,
@@ -488,9 +488,16 @@ describe('Pattern Detail Page', () => {
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 Watch video link when video is absent', async () => {
+ 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 }))