Skip to content

Commit 49caeb9

Browse files
ostermanclaudeaknysh
authored
Redesign changelog page with vertical timeline (#1818)
* feat: Redesign changelog page with vertical timeline and collapsible sidebar Replace the narrow sidebar layout with a full-width vertical timeline view featuring: - Year/month-based timeline grouping with alternate left/right entry positioning - Filter controls for year and tag selection - Collapsible sidebar on individual blog posts (collapsed by default) - Full abstract display (content before <!--truncate-->) on timeline cards - Smooth hover effects with color transitions and connector line animations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * chore: Remove dead code and duplicate keyframes - Remove unused useEffect and import from BlogSidebar/Desktop - Remove duplicate @Keyframes gradient definition (uses global from custom.css) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Andriy Knysh <[email protected]>
1 parent 103aa0d commit 49caeb9

File tree

15 files changed

+1409
-0
lines changed

15 files changed

+1409
-0
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react';
2+
import clsx from 'clsx';
3+
import styles from './styles.module.css';
4+
5+
interface FilterBarProps {
6+
years: string[];
7+
tags: string[];
8+
selectedYear: string | null;
9+
selectedTag: string | null;
10+
onYearChange: (year: string | null) => void;
11+
onTagChange: (tag: string | null) => void;
12+
}
13+
14+
export default function FilterBar({
15+
years,
16+
tags,
17+
selectedYear,
18+
selectedTag,
19+
onYearChange,
20+
onTagChange,
21+
}: FilterBarProps): JSX.Element {
22+
return (
23+
<div className={styles.filterBar}>
24+
<div className={styles.filterRow}>
25+
<div className={styles.yearFilters} role="group" aria-label="Filter by year">
26+
<button
27+
className={clsx(styles.filterPill, !selectedYear && styles.filterPillActive)}
28+
onClick={() => onYearChange(null)}
29+
aria-pressed={!selectedYear}
30+
>
31+
All
32+
</button>
33+
{years.map((year) => (
34+
<button
35+
key={year}
36+
className={clsx(
37+
styles.filterPill,
38+
selectedYear === year && styles.filterPillActive
39+
)}
40+
onClick={() => onYearChange(year)}
41+
aria-pressed={selectedYear === year}
42+
>
43+
{year}
44+
</button>
45+
))}
46+
</div>
47+
48+
<div className={styles.tagFilter}>
49+
<label htmlFor="tag-filter" className={styles.tagFilterLabel}>
50+
Filter by:
51+
</label>
52+
<select
53+
id="tag-filter"
54+
value={selectedTag || ''}
55+
onChange={(e) => onTagChange(e.target.value || null)}
56+
className={styles.tagSelect}
57+
aria-label="Filter by tag"
58+
>
59+
<option value="">All Tags</option>
60+
{tags.map((tag) => (
61+
<option key={tag} value={tag}>
62+
{tag}
63+
</option>
64+
))}
65+
</select>
66+
</div>
67+
</div>
68+
</div>
69+
);
70+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import Link from '@docusaurus/Link';
3+
import clsx from 'clsx';
4+
import type { BlogPostItem } from './utils';
5+
import { getTagColorClass } from './utils';
6+
import styles from './styles.module.css';
7+
8+
interface TimelineEntryProps {
9+
item: BlogPostItem;
10+
position: 'left' | 'right';
11+
}
12+
13+
export default function TimelineEntry({
14+
item,
15+
position,
16+
}: TimelineEntryProps): JSX.Element {
17+
const { metadata } = item.content;
18+
const { title, permalink, date, tags = [], description } = metadata;
19+
20+
const formattedDate = new Date(date).toLocaleDateString('en-US', {
21+
month: 'short',
22+
day: 'numeric',
23+
});
24+
25+
return (
26+
<article
27+
className={clsx(styles.entry, styles[`entry${position.charAt(0).toUpperCase() + position.slice(1)}`])}
28+
aria-label={title}
29+
>
30+
<div className={styles.entryNode} aria-hidden="true" />
31+
<div className={styles.entryConnector} aria-hidden="true" />
32+
<div className={styles.entryCard}>
33+
<div className={styles.entryHeader}>
34+
<time dateTime={date} className={styles.entryDate}>
35+
{formattedDate}
36+
</time>
37+
{tags.length > 0 && (
38+
<div className={styles.entryTags}>
39+
{tags.slice(0, 3).map((tag) => (
40+
<span
41+
key={tag.label}
42+
className={clsx(styles.tag, styles[getTagColorClass(tag.label)])}
43+
>
44+
{tag.label}
45+
</span>
46+
))}
47+
</div>
48+
)}
49+
</div>
50+
<Link to={permalink} className={styles.entryTitle}>
51+
<h3>{title}</h3>
52+
</Link>
53+
{description && (
54+
<p className={styles.entryExcerpt}>{description}</p>
55+
)}
56+
</div>
57+
</article>
58+
);
59+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
import TimelineEntry from './TimelineEntry';
3+
import type { MonthGroup } from './utils';
4+
import styles from './styles.module.css';
5+
6+
interface TimelineMonthProps {
7+
monthGroup: MonthGroup;
8+
startIndex: number;
9+
}
10+
11+
export default function TimelineMonth({
12+
monthGroup,
13+
startIndex,
14+
}: TimelineMonthProps): JSX.Element {
15+
const { month, items } = monthGroup;
16+
17+
return (
18+
<div className={styles.monthGroup}>
19+
<div className={styles.monthSeparator}>
20+
<span className={styles.monthText}>{month}</span>
21+
</div>
22+
<div className={styles.entriesContainer}>
23+
{items.map((item, index) => (
24+
<TimelineEntry
25+
key={item.content.metadata.permalink}
26+
item={item}
27+
position={(startIndex + index) % 2 === 0 ? 'left' : 'right'}
28+
/>
29+
))}
30+
</div>
31+
</div>
32+
);
33+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react';
2+
import TimelineMonth from './TimelineMonth';
3+
import type { YearGroup } from './utils';
4+
import styles from './styles.module.css';
5+
6+
interface TimelineYearProps {
7+
yearGroup: YearGroup;
8+
}
9+
10+
export default function TimelineYear({
11+
yearGroup,
12+
}: TimelineYearProps): JSX.Element {
13+
const { year, months } = yearGroup;
14+
15+
// Calculate running index for alternating positions across months.
16+
let runningIndex = 0;
17+
18+
return (
19+
<section className={styles.yearSection} aria-label={`${year} releases`}>
20+
<div className={styles.yearMarker}>
21+
<span className={styles.yearText}>{year}</span>
22+
</div>
23+
{months.map((monthGroup) => {
24+
const startIndex = runningIndex;
25+
runningIndex += monthGroup.items.length;
26+
return (
27+
<TimelineMonth
28+
key={`${year}-${monthGroup.month}`}
29+
monthGroup={monthGroup}
30+
startIndex={startIndex}
31+
/>
32+
);
33+
})}
34+
</section>
35+
);
36+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, { useState, useMemo } from 'react';
2+
import FilterBar from './FilterBar';
3+
import TimelineYear from './TimelineYear';
4+
import {
5+
groupBlogPostsByYearMonth,
6+
extractYears,
7+
extractTags,
8+
filterBlogPosts,
9+
type BlogPostItem,
10+
} from './utils';
11+
import styles from './styles.module.css';
12+
13+
interface ChangelogTimelineProps {
14+
items: BlogPostItem[];
15+
}
16+
17+
export default function ChangelogTimeline({
18+
items,
19+
}: ChangelogTimelineProps): JSX.Element {
20+
const [selectedYear, setSelectedYear] = useState<string | null>(null);
21+
const [selectedTag, setSelectedTag] = useState<string | null>(null);
22+
23+
// Extract available years and tags for the filter.
24+
const years = useMemo(() => extractYears(items), [items]);
25+
const tags = useMemo(() => extractTags(items), [items]);
26+
27+
// Filter and group items.
28+
const filteredItems = useMemo(
29+
() => filterBlogPosts(items, selectedYear, selectedTag),
30+
[items, selectedYear, selectedTag]
31+
);
32+
33+
const groupedItems = useMemo(
34+
() => groupBlogPostsByYearMonth(filteredItems),
35+
[filteredItems]
36+
);
37+
38+
const hasResults = groupedItems.length > 0;
39+
40+
return (
41+
<div className={styles.changelogTimeline}>
42+
<FilterBar
43+
years={years}
44+
tags={tags}
45+
selectedYear={selectedYear}
46+
selectedTag={selectedTag}
47+
onYearChange={setSelectedYear}
48+
onTagChange={setSelectedTag}
49+
/>
50+
51+
{hasResults ? (
52+
<div className={styles.timeline}>
53+
<div className={styles.timelineLine} aria-hidden="true" />
54+
{groupedItems.map((yearGroup) => (
55+
<TimelineYear key={yearGroup.year} yearGroup={yearGroup} />
56+
))}
57+
</div>
58+
) : (
59+
<div className={styles.emptyState}>
60+
<p>No changelog entries found matching your filters.</p>
61+
<button
62+
className={styles.resetButton}
63+
onClick={() => {
64+
setSelectedYear(null);
65+
setSelectedTag(null);
66+
}}
67+
>
68+
Clear filters
69+
</button>
70+
</div>
71+
)}
72+
</div>
73+
);
74+
}
75+
76+
export { type BlogPostItem } from './utils';

0 commit comments

Comments
 (0)