Skip to content

Commit f2afcc8

Browse files
committed
Add a "Recent Editors" Component with Volunteer Contribution Message (698fa238784a62c36b3b13e1)
1 parent 5972d57 commit f2afcc8

3 files changed

Lines changed: 307 additions & 1 deletion

File tree

‎app/docs/(content)/[...slug]/page.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
33
import { MDXRemote } from "next-mdx-remote/rsc";
44
import { Suspense } from "react";
55

6+
import { DocsEditors } from "@/components/docs/content/docs-editors";
67
import { DocsHeader } from "@/components/docs/content/docs-header";
78
import { DocsNavigation } from "@/components/docs/content/docs-navigation";
89
import { EditOnGitHub } from "@/components/docs/content/edit-on-github";
@@ -14,6 +15,7 @@ import { HotReload } from "@/components/docs/hot-reload";
1415
import { ArticleSchema } from "@/components/seo/article-schema";
1516
import { BreadcrumbSchema } from "@/components/seo/breadcrumb-schema";
1617
import { components, mdxOptions } from "@/components/ui/mdx/mdx-components";
18+
import { getDocEditors } from "@/lib/docs/editors";
1719
import { getAllDocs, getDoc } from "@/lib/docs/loader";
1820
import { getDocNavigation, getSidebar as getSidebarStructure } from "@/lib/docs/sidebar";
1921

@@ -77,7 +79,11 @@ export default async function DocPage({ params }: Props) {
7779
const resolvedParams = await params;
7880

7981
// Parallel data fetching - avoid waterfall
80-
const [doc, sidebar] = await Promise.all([getDoc(resolvedParams.slug), getSidebarStructure()]);
82+
const [doc, sidebar, docEditors] = await Promise.all([
83+
getDoc(resolvedParams.slug),
84+
getSidebarStructure(),
85+
getDocEditors(resolvedParams.slug.join("/")),
86+
]);
8187

8288
if (!doc) {
8389
const currentPath = `/docs/${resolvedParams.slug.join("/")}`;
@@ -180,6 +186,14 @@ export default async function DocPage({ params }: Props) {
180186
icon={doc.frontmatter.icon}
181187
title={doc.frontmatter.title}
182188
/>
189+
{!!docEditors?.editors.length && (
190+
<DocsEditors
191+
className="mb-6"
192+
editors={docEditors.editors}
193+
edits={docEditors.edits}
194+
historyUrl={docEditors.historyUrl}
195+
/>
196+
)}
183197

184198
<hr className="my-6 border-gray-200 sm:mx-auto dark:border-gray-700" />
185199

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"use client";
2+
3+
import { m } from "framer-motion";
4+
import Image from "next/image";
5+
import Link from "next/link";
6+
import { useReducedMotion } from "@/hooks/use-reduced-motion";
7+
import { cn } from "@/lib/utils";
8+
9+
interface DocEditor {
10+
login: string;
11+
avatarUrl: string;
12+
profileUrl: string;
13+
}
14+
15+
interface DocsEditorsProps {
16+
editors: DocEditor[];
17+
historyUrl: string;
18+
edits: DocEdit[];
19+
className?: string;
20+
}
21+
22+
interface DocEdit {
23+
sha: string;
24+
url: string;
25+
message: string;
26+
authorLogin: string | null;
27+
authorAvatarUrl: string | null;
28+
authorProfileUrl: string | null;
29+
authoredAt: string | null;
30+
}
31+
32+
const getCommitTitle = (message: string) =>
33+
message.split("\n")[0]?.trim() || "Update documentation";
34+
35+
export const DocsEditors = ({ editors, historyUrl, edits, className }: DocsEditorsProps) => {
36+
const prefersReducedMotion = useReducedMotion();
37+
const renderEditAvatar = (edit: DocEdit) => {
38+
if (!edit.authorAvatarUrl) {
39+
return null;
40+
}
41+
42+
if (edit.authorProfileUrl) {
43+
return (
44+
<a
45+
aria-label={`View ${edit.authorLogin ?? "contributor"}'s GitHub profile`}
46+
className="relative h-6 w-6 overflow-hidden rounded-full border border-white shadow-sm dark:border-gray-900"
47+
href={edit.authorProfileUrl}
48+
rel="noopener noreferrer"
49+
target="_blank"
50+
title={edit.authorLogin ? `@${edit.authorLogin}` : "View contributor"}
51+
>
52+
<Image
53+
alt={`Avatar of ${edit.authorLogin ?? "contributor"}`}
54+
fill
55+
sizes="24px"
56+
src={edit.authorAvatarUrl}
57+
/>
58+
</a>
59+
);
60+
}
61+
62+
return (
63+
<span className="relative h-6 w-6 overflow-hidden rounded-full border border-white shadow-sm dark:border-gray-900">
64+
<Image
65+
alt={`Avatar of ${edit.authorLogin ?? "contributor"}`}
66+
fill
67+
sizes="24px"
68+
src={edit.authorAvatarUrl}
69+
/>
70+
</span>
71+
);
72+
};
73+
74+
return (
75+
<m.section
76+
animate={{ opacity: 1, y: 0 }}
77+
className={cn(
78+
"relative overflow-hidden rounded-2xl border border-gray-200 bg-white/95 p-5 shadow-sm backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/60",
79+
className
80+
)}
81+
initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 8 }}
82+
transition={{ duration: prefersReducedMotion ? 0 : 0.4, ease: [0.4, 0, 0.2, 1] }}
83+
>
84+
<div className="flex flex-col gap-4">
85+
<div>
86+
<p className="font-semibold text-gray-500 text-xs uppercase tracking-[0.2em] dark:text-gray-400">
87+
This documentation page was created by volunteers.{" "}
88+
<Link
89+
className="text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
90+
href="/contribute"
91+
>
92+
Learn how to contribute
93+
</Link>
94+
</p>
95+
<p className="mt-2 font-semibold text-gray-900 text-sm dark:text-gray-100">
96+
Recent editors for this page
97+
</p>
98+
</div>
99+
100+
<details className="group rounded-xl border border-gray-200 bg-gray-50/80 px-4 py-3 text-gray-600 text-sm dark:border-gray-800 dark:bg-gray-900/70 dark:text-gray-300">
101+
<summary className="flex cursor-pointer list-none items-center justify-between gap-4">
102+
<div>
103+
<p className="font-semibold text-gray-900 text-sm dark:text-gray-100">Latest edits</p>
104+
<p className="text-gray-500 text-xs dark:text-gray-400">
105+
Expand to view recent commits.
106+
</p>
107+
</div>
108+
<div className="flex items-center gap-3">
109+
<div className="flex items-center -space-x-2">
110+
{editors.map((editor) => (
111+
<a
112+
aria-label={`View ${editor.login}'s GitHub profile`}
113+
className="relative h-8 w-8 overflow-hidden rounded-full border-2 border-white shadow-sm transition-transform hover:-translate-y-0.5 dark:border-gray-900"
114+
href={editor.profileUrl}
115+
key={editor.login}
116+
rel="noopener noreferrer"
117+
target="_blank"
118+
title={`@${editor.login}`}
119+
>
120+
<Image
121+
alt={`Avatar of ${editor.login}`}
122+
fill
123+
sizes="32px"
124+
src={editor.avatarUrl}
125+
/>
126+
</a>
127+
))}
128+
</div>
129+
<span className="font-medium text-gray-500 text-xs group-open:hidden dark:text-gray-400">
130+
Show
131+
</span>
132+
<span className="hidden font-medium text-gray-500 text-xs group-open:inline dark:text-gray-400">
133+
Hide
134+
</span>
135+
</div>
136+
</summary>
137+
138+
<div className="mt-3 border-gray-200/70 border-t pt-3 dark:border-gray-800/80">
139+
<ul className="flex flex-col gap-2">
140+
{edits.map((edit) => (
141+
<li className="flex flex-wrap items-center gap-x-2 gap-y-1" key={edit.sha}>
142+
{renderEditAvatar(edit)}
143+
<a
144+
className="font-semibold text-gray-700 transition-colors hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-300"
145+
href={edit.url}
146+
rel="noopener noreferrer"
147+
target="_blank"
148+
>
149+
{getCommitTitle(edit.message)}
150+
</a>
151+
{edit.authorLogin ? (
152+
<span className="text-gray-500 text-xs dark:text-gray-400">
153+
by {edit.authorLogin}
154+
</span>
155+
) : null}
156+
</li>
157+
))}
158+
</ul>
159+
<a
160+
className="mt-3 inline-flex items-center gap-2 font-semibold text-gray-500 text-sm transition-colors hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-300"
161+
href={historyUrl}
162+
rel="noopener noreferrer"
163+
target="_blank"
164+
>
165+
View full history
166+
<span aria-hidden="true">↗</span>
167+
</a>
168+
</div>
169+
</details>
170+
</div>
171+
</m.section>
172+
);
173+
};

‎lib/docs/editors.ts‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
export interface DocEditor {
2+
login: string;
3+
avatarUrl: string;
4+
profileUrl: string;
5+
}
6+
7+
export interface DocEditorsResult {
8+
editors: DocEditor[];
9+
historyUrl: string;
10+
edits: DocEdit[];
11+
}
12+
13+
export interface DocEdit {
14+
sha: string;
15+
url: string;
16+
message: string;
17+
authorLogin: string | null;
18+
authorAvatarUrl: string | null;
19+
authorProfileUrl: string | null;
20+
authoredAt: string | null;
21+
}
22+
23+
interface GitHubCommit {
24+
sha: string;
25+
html_url: string;
26+
author: {
27+
login: string;
28+
avatar_url: string;
29+
html_url: string;
30+
} | null;
31+
commit: {
32+
message?: string;
33+
author?: {
34+
date?: string;
35+
} | null;
36+
committer?: {
37+
date?: string;
38+
} | null;
39+
};
40+
}
41+
42+
const REPOSITORY = "EternalCodeTeam/website";
43+
const CONTENT_PREFIX = "content/docs";
44+
const EDITORS_LIMIT = 3;
45+
46+
export async function getDocEditors(filePath: string): Promise<DocEditorsResult | null> {
47+
const path = `${CONTENT_PREFIX}/${filePath}.mdx`;
48+
const token = process.env.GITHUB_TOKEN;
49+
const headers: HeadersInit = {
50+
Accept: "application/vnd.github+json",
51+
};
52+
53+
if (token) {
54+
headers.Authorization = `token ${token}`;
55+
}
56+
57+
try {
58+
const response = await fetch(
59+
`https://api.github.com/repos/${REPOSITORY}/commits?path=${encodeURIComponent(path)}&per_page=10`,
60+
{
61+
headers,
62+
next: {
63+
revalidate: 3600,
64+
},
65+
}
66+
);
67+
68+
if (!response.ok) {
69+
console.error(`Failed to fetch doc editors for ${path}: ${response.statusText}`);
70+
return null;
71+
}
72+
73+
const commits = (await response.json()) as GitHubCommit[];
74+
75+
if (!commits.length) {
76+
return null;
77+
}
78+
79+
const editors: DocEditor[] = [];
80+
const seen = new Set<string>();
81+
82+
for (const commit of commits) {
83+
if (!commit.author?.login || seen.has(commit.author.login)) {
84+
continue;
85+
}
86+
87+
editors.push({
88+
login: commit.author.login,
89+
avatarUrl: commit.author.avatar_url,
90+
profileUrl: commit.author.html_url,
91+
});
92+
seen.add(commit.author.login);
93+
94+
if (editors.length >= EDITORS_LIMIT) {
95+
break;
96+
}
97+
}
98+
99+
const edits = commits.slice(0, 5).map((commit) => ({
100+
sha: commit.sha,
101+
url: commit.html_url,
102+
message: commit.commit.message?.trim() || "Update documentation",
103+
authorLogin: commit.author?.login ?? null,
104+
authorAvatarUrl: commit.author?.avatar_url ?? null,
105+
authorProfileUrl: commit.author?.html_url ?? null,
106+
authoredAt: commit.commit.author?.date ?? commit.commit.committer?.date ?? null,
107+
}));
108+
const historyUrl = `https://github.com/${REPOSITORY}/commits/master/${path}`;
109+
110+
return {
111+
editors,
112+
historyUrl,
113+
edits,
114+
};
115+
} catch (error) {
116+
console.error(`Error fetching doc editors for ${path}:`, error);
117+
return null;
118+
}
119+
}

0 commit comments

Comments
 (0)