Skip to content

Commit 9efd659

Browse files
committed
feat: improve readability of changelog and security advisory pages
- changelog now uses a sticky version sidebar with collapsible major-version groups, color-coded entry categories, and working hash-anchor scrolling into collapsed groups - security advisory breadcrumb now reads "Security Advisory <date>" and is legible in dark mode - security advisory infobox restyled as a themed two-column card
1 parent 1241ab6 commit 9efd659

6 files changed

Lines changed: 501 additions & 14 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useState } from 'react';
4+
import type { ChangelogGroup } from '@/lib/changelog';
5+
import styles from './changelog.module.scss';
6+
7+
interface ChangelogViewProps {
8+
groups: ChangelogGroup[];
9+
}
10+
11+
function formatDate(date: string): string {
12+
const d = new Date(date);
13+
if (isNaN(d.getTime())) return date;
14+
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' });
15+
}
16+
17+
// Maps the leading verb of a changelog entry to a category for colour-coded tags.
18+
function itemCategory(html: string): string {
19+
const word = html.replace(/<[^>]+>/g, '').trim().split(/\s+/)[0]?.toLowerCase() ?? '';
20+
if (['added', 'new'].includes(word)) return 'added';
21+
if (['fixed'].includes(word)) return 'fixed';
22+
if (['removed', 'dropped', 'changed', 'switched'].includes(word)) return 'changed';
23+
return 'updated';
24+
}
25+
26+
export default function ChangelogView({ groups }: ChangelogViewProps) {
27+
const [expanded, setExpanded] = useState<Record<string, boolean>>(() =>
28+
Object.fromEntries(groups.map((g, i) => [g.slug, i === 0])),
29+
);
30+
31+
const [pendingScroll, setPendingScroll] = useState<string | null>(null);
32+
33+
const toggle = (slug: string) => setExpanded((prev) => ({ ...prev, [slug]: !prev[slug] }));
34+
35+
// Opens the group containing the hash-targeted release and queues a scroll to it.
36+
const revealHash = useCallback(() => {
37+
const anchor = decodeURIComponent(window.location.hash.replace(/^#/, ''));
38+
if (!anchor) return;
39+
const group = groups.find((g) => g.releases.some((r) => r.anchor === anchor));
40+
if (!group) return;
41+
setExpanded((prev) => ({ ...prev, [group.slug]: true }));
42+
setPendingScroll(anchor);
43+
}, [groups]);
44+
45+
useEffect(() => {
46+
revealHash();
47+
window.addEventListener('hashchange', revealHash);
48+
return () => window.removeEventListener('hashchange', revealHash);
49+
}, [revealHash]);
50+
51+
// Runs after the target group has rendered its releases, so the element exists.
52+
useEffect(() => {
53+
if (!pendingScroll) return;
54+
const el = document.getElementById(pendingScroll);
55+
if (el) {
56+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
57+
setPendingScroll(null);
58+
}
59+
}, [pendingScroll, expanded]);
60+
61+
const jumpTo = (slug: string) => {
62+
setExpanded((prev) => ({ ...prev, [slug]: true }));
63+
requestAnimationFrame(() => {
64+
document.getElementById(`group-${slug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
65+
});
66+
};
67+
68+
return (
69+
<div className={styles.layout}>
70+
<nav className={styles.sidebar} aria-label="Changelog versions">
71+
<span className={styles.sidebarTitle}>Versions</span>
72+
<ul className={styles.sidebarList}>
73+
{groups.map((group) => (
74+
<li key={group.slug}>
75+
<button type="button" className={styles.sidebarLink} onClick={() => jumpTo(group.slug)}>
76+
{group.slug}
77+
<span className={styles.sidebarCount}>{group.releases.length}</span>
78+
</button>
79+
</li>
80+
))}
81+
</ul>
82+
</nav>
83+
84+
<div className={styles.content}>
85+
{groups.map((group) => {
86+
const isOpen = expanded[group.slug];
87+
return (
88+
<section key={group.slug} id={`group-${group.slug}`} className={styles.group}>
89+
<button
90+
type="button"
91+
className={styles.groupHeader}
92+
onClick={() => toggle(group.slug)}
93+
aria-expanded={isOpen}
94+
>
95+
<span className={styles.chevron} data-open={isOpen}>
96+
97+
</span>
98+
<h2>{group.name}</h2>
99+
<span className={styles.groupMeta}>
100+
{group.releases.length} {group.releases.length === 1 ? 'release' : 'releases'}
101+
</span>
102+
</button>
103+
104+
{isOpen && (
105+
<div className={styles.releases}>
106+
{group.releases.map((release) => (
107+
<article key={release.anchor} id={release.anchor} className={styles.release}>
108+
<div className={styles.releaseHead}>
109+
<h3>{release.version}</h3>
110+
<time className={styles.releaseDate}>{formatDate(release.date)}</time>
111+
</div>
112+
<ul className={styles.items}>
113+
{release.items.map((item, idx) => {
114+
const cat = itemCategory(item);
115+
return (
116+
<li key={idx} className={styles.item} data-cat={cat}>
117+
<span className={styles.itemDot} aria-hidden="true" />
118+
<span dangerouslySetInnerHTML={{ __html: item }} />
119+
</li>
120+
);
121+
})}
122+
</ul>
123+
</article>
124+
))}
125+
</div>
126+
)}
127+
</section>
128+
);
129+
})}
130+
</div>
131+
</div>
132+
);
133+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
.layout {
2+
display: grid;
3+
grid-template-columns: 200px 1fr;
4+
gap: 2.5rem;
5+
align-items: start;
6+
}
7+
8+
/* ---- Sidebar ---- */
9+
10+
.sidebar {
11+
position: sticky;
12+
top: 1.5rem;
13+
align-self: start;
14+
}
15+
16+
.sidebarTitle {
17+
display: block;
18+
font-size: 0.72rem;
19+
font-weight: 700;
20+
letter-spacing: 0.08em;
21+
text-transform: uppercase;
22+
color: var(--color-text-muted);
23+
padding: 0 0.6rem 0.5rem;
24+
}
25+
26+
.sidebarList {
27+
list-style: none;
28+
margin: 0;
29+
padding: 0;
30+
max-height: calc(100vh - 6rem);
31+
overflow-y: auto;
32+
}
33+
34+
.sidebarLink {
35+
display: flex;
36+
align-items: center;
37+
justify-content: space-between;
38+
width: 100%;
39+
gap: 0.5rem;
40+
padding: 0.4rem 0.6rem;
41+
border: 0;
42+
border-radius: 7px;
43+
background: transparent;
44+
color: var(--color-text);
45+
font-size: 0.9rem;
46+
font-weight: 500;
47+
text-align: left;
48+
cursor: pointer;
49+
transition: background 0.15s ease;
50+
51+
&:hover {
52+
background: var(--color-bg-secondary);
53+
color: var(--color-primary);
54+
}
55+
}
56+
57+
.sidebarCount {
58+
font-size: 0.72rem;
59+
color: var(--color-text-muted);
60+
background: var(--color-bg-secondary);
61+
padding: 0.05rem 0.45rem;
62+
border-radius: 999px;
63+
}
64+
65+
.sidebarLink:hover .sidebarCount {
66+
background: var(--color-background, #fff);
67+
}
68+
69+
/* ---- Content ---- */
70+
71+
.content {
72+
min-width: 0;
73+
}
74+
75+
.group {
76+
margin-bottom: 1rem;
77+
border: 1px solid var(--color-border);
78+
border-radius: 10px;
79+
overflow: hidden;
80+
scroll-margin-top: 1.5rem;
81+
}
82+
83+
.groupHeader {
84+
display: flex;
85+
align-items: center;
86+
gap: 0.7rem;
87+
width: 100%;
88+
padding: 0.9rem 1.2rem;
89+
border: 0;
90+
background: var(--color-bg-secondary);
91+
cursor: pointer;
92+
text-align: left;
93+
94+
h2 {
95+
font-size: 1.15rem !important;
96+
font-weight: 600;
97+
margin: 0 !important;
98+
color: var(--color-text);
99+
}
100+
}
101+
102+
.chevron {
103+
display: inline-block;
104+
font-size: 0.85rem;
105+
color: var(--color-text-muted);
106+
transition: transform 0.18s ease;
107+
108+
&[data-open='true'] {
109+
transform: rotate(90deg);
110+
}
111+
}
112+
113+
.groupMeta {
114+
margin-left: auto;
115+
font-size: 0.78rem;
116+
color: var(--color-text-muted);
117+
white-space: nowrap;
118+
}
119+
120+
.releases {
121+
padding: 0.5rem 1.2rem 1rem;
122+
}
123+
124+
.release {
125+
padding: 1rem 0;
126+
border-bottom: 1px solid var(--color-border);
127+
scroll-margin-top: 1.5rem;
128+
129+
&:last-child {
130+
border-bottom: 0;
131+
padding-bottom: 0;
132+
}
133+
}
134+
135+
.releaseHead {
136+
display: flex;
137+
align-items: baseline;
138+
gap: 0.75rem;
139+
margin-bottom: 0.6rem;
140+
141+
h3 {
142+
font-size: 1rem !important;
143+
font-weight: 700;
144+
margin: 0 !important;
145+
color: var(--color-text);
146+
}
147+
}
148+
149+
.releaseDate {
150+
font-size: 0.78rem;
151+
font-weight: 600;
152+
letter-spacing: 0.03em;
153+
text-transform: uppercase;
154+
color: var(--color-text-muted);
155+
}
156+
157+
.items {
158+
list-style: none;
159+
margin: 0;
160+
padding: 0;
161+
display: grid;
162+
gap: 0.3rem;
163+
}
164+
165+
.item {
166+
display: flex;
167+
align-items: baseline;
168+
gap: 0.6rem;
169+
font-size: 0.92rem;
170+
line-height: 1.5;
171+
color: var(--color-text);
172+
173+
a {
174+
color: var(--color-primary);
175+
}
176+
}
177+
178+
.itemDot {
179+
flex: 0 0 auto;
180+
width: 7px;
181+
height: 7px;
182+
margin-top: 0.1rem;
183+
border-radius: 50%;
184+
background: var(--color-text-muted);
185+
}
186+
187+
.item[data-cat='added'] .itemDot {
188+
background: #16a34a;
189+
}
190+
.item[data-cat='fixed'] .itemDot {
191+
background: #2563eb;
192+
}
193+
.item[data-cat='changed'] .itemDot {
194+
background: #ea580c;
195+
}
196+
.item[data-cat='updated'] .itemDot {
197+
background: #94a3b8;
198+
}
199+
200+
@media (max-width: 768px) {
201+
.layout {
202+
grid-template-columns: 1fr;
203+
gap: 1rem;
204+
}
205+
206+
.sidebar {
207+
position: static;
208+
border: 1px solid var(--color-border);
209+
border-radius: 10px;
210+
padding: 0.6rem;
211+
}
212+
213+
.sidebarList {
214+
display: flex;
215+
flex-wrap: wrap;
216+
gap: 0.3rem;
217+
max-height: none;
218+
}
219+
220+
.sidebarLink {
221+
width: auto;
222+
}
223+
}

src/app/changelog/page.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,21 @@
11
import PageLayout from '@/components/PageLayout';
22
import { generatePageMetadata } from '@/components/PageLayout';
33
import { Metadata } from 'next';
4-
import { getChangelogContent } from '@/lib/changelog';
4+
import { getChangelogData } from '@/lib/changelog';
5+
import ChangelogView from './ChangelogView';
56

67
export const metadata: Metadata = generatePageMetadata(
78
'Changelog',
89
'User visible changes in phpMyFAQ releases since 2001 until today',
910
);
1011

1112
export default async function ChangelogPage() {
12-
const { content } = await getChangelogContent();
13+
const { groups } = await getChangelogData();
1314

1415
return (
1516
<PageLayout title="Changelog">
16-
<div className="row">
17-
<div className="col-12">
18-
<p className="lead">User visible changes in phpMyFAQ releases since 2001 until today</p>
19-
20-
<div className="changelog-content">
21-
<div dangerouslySetInnerHTML={{ __html: content }} />
22-
</div>
23-
</div>
24-
</div>
17+
<p className="lead">User visible changes in phpMyFAQ releases since 2001 until today</p>
18+
<ChangelogView groups={groups} />
2519
</PageLayout>
2620
);
2721
}

0 commit comments

Comments
 (0)