Render blog posts from Forgejo

This commit is contained in:
kacper 2026-04-12 20:23:05 -04:00
parent 6671a01d26
commit 0a9b052beb
6 changed files with 366 additions and 48 deletions

View file

@ -77,6 +77,19 @@ function parseCourseRoute(pathname: string): { owner: string; repo: string } | n
};
}
function parsePostRoute(pathname: string): { owner: string; repo: string; post: string } | null {
const match = normalizePathname(pathname).match(/^\/posts\/([^/]+)\/([^/]+)\/([^/]+)$/);
if (!match) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
post: decodeURIComponent(match[3]),
};
}
function parseLessonRoute(pathname: string): {
owner: string;
repo: string;
@ -124,6 +137,27 @@ function findCourseByRoute(
});
}
function findPostByRoute(
posts: PostCard[],
route: { owner: string; repo: string; post: string },
): PostCard | undefined {
const routeOwner = normalizeRouteKey(route.owner);
const routeRepo = normalizeRouteKey(route.repo);
const routePost = normalizeRouteKey(route.post);
const routeFullName = `${routeOwner}/${routeRepo}`;
return posts.find((post) => {
const postOwner = normalizeRouteKey(post.owner);
const postRepo = normalizeRouteKey(post.name);
const postFullName = normalizeRouteKey(post.repo);
return (
postOwner === routeOwner &&
(postRepo === routeRepo || postFullName === routeFullName) &&
normalizeRouteKey(post.slug) === routePost
);
});
}
function findLessonByRoute(
course: CourseCard,
route: { chapter: string; lesson: string },
@ -356,15 +390,23 @@ function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCa
);
}
function PostItem(props: { post: PostCard }) {
const { post } = props;
function PostItem(props: { post: PostCard; onOpenPost: (post: PostCard) => void }) {
const { post, onOpenPost } = props;
return (
<article className="card">
<button
type="button"
className="card post-card-button"
onClick={() => {
onOpenPost(post);
}}
>
<h3>{post.title}</h3>
<p className="muted-copy">{post.summary}</p>
<p className="meta-line">{post.repo}</p>
</article>
<p className="meta-line">
{post.repo} · {post.file_path || post.path}
</p>
</button>
);
}
@ -637,6 +679,40 @@ function LessonPage(props: {
);
}
function PostPage(props: { post: PostCard; onGoHome: () => void }) {
const { post, onGoHome } = props;
const postBody = stripLeadingTitleHeading(post.body, post.title);
return (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoHome}>
Back to home
</button>
<article className="panel">
<header className="thread-header">
<h1>{post.title}</h1>
<p className="meta-line">
{post.repo} · {formatTimestamp(post.updated_at)}
</p>
</header>
{post.summary ? <p className="muted-copy">{post.summary}</p> : null}
</article>
<article className="panel">
<header className="subsection-header">
<h2>Post</h2>
</header>
{postBody ? (
<MarkdownContent markdown={postBody} className="thread-copy" />
) : (
<EmptyState copy="This post file is empty or could not be read from Forgejo." />
)}
</article>
</section>
);
}
function DiscussionPage(props: {
discussion: DiscussionCard;
auth: AuthState;
@ -739,9 +815,10 @@ function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: nu
function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
onOpenDiscussion: (id: number) => void;
}) {
const { data, onOpenCourse, onOpenDiscussion } = props;
const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props;
return (
<>
@ -761,7 +838,7 @@ function HomeView(props: {
{data.recent_posts.length > 0 ? (
<div className="stack">
{data.recent_posts.map((post) => (
<PostItem key={`${post.repo}:${post.title}`} post={post} />
<PostItem key={`${post.repo}:${post.slug}`} post={post} onOpenPost={onOpenPost} />
))}
</div>
) : (
@ -886,6 +963,10 @@ function coursePath(course: CourseCard): string {
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`;
}
function postPath(post: PostCard): string {
return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`;
}
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
const activities: ActivityEntry[] = [];
@ -904,11 +985,11 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
for (const post of data.recent_posts) {
if (post.updated_at) {
activities.push({
id: `post:${post.repo}`,
title: `Post repo updated: ${post.title}`,
id: `post:${post.repo}:${post.slug}`,
title: `Post updated: ${post.title}`,
detail: post.repo,
timestamp: post.updated_at,
route: null,
route: postPath(post),
});
}
}
@ -991,6 +1072,7 @@ interface AppContentProps {
data: PrototypeData;
pathname: string;
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void;
onOpenRoute: (route: string) => void;
@ -1001,6 +1083,24 @@ interface AppContentProps {
onGoDiscussions: () => void;
}
function PostRouteView(
props: AppContentProps & { route: { owner: string; repo: string; post: string } },
) {
const selectedPost = findPostByRoute(props.data.recent_posts, props.route);
if (!selectedPost) {
return (
<section className="page-message">
<h1>Post not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to home
</button>
</section>
);
}
return <PostPage post={selectedPost} onGoHome={props.onGoHome} />;
}
function LessonRouteView(
props: AppContentProps & {
route: { owner: string; repo: string; chapter: string; lesson: string };
@ -1113,6 +1213,11 @@ function AppContent(props: AppContentProps) {
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />;
}
const postRoute = parsePostRoute(props.pathname);
if (postRoute !== null) {
return <PostRouteView {...props} route={postRoute} />;
}
const lessonRoute = parseLessonRoute(props.pathname);
if (lessonRoute !== null) {
return <LessonRouteView {...props} route={lessonRoute} />;
@ -1129,6 +1234,7 @@ function AppContent(props: AppContentProps) {
<HomeView
data={props.data}
onOpenCourse={props.onOpenCourse}
onOpenPost={props.onOpenPost}
onOpenDiscussion={props.onOpenDiscussion}
/>
);
@ -1160,6 +1266,7 @@ function LoadedApp(
data={props.data}
pathname={props.pathname}
onOpenCourse={props.onOpenCourse}
onOpenPost={props.onOpenPost}
onOpenLesson={props.onOpenLesson}
onOpenDiscussion={props.onOpenDiscussion}
onOpenRoute={props.onOpenRoute}
@ -1214,30 +1321,24 @@ export default function App() {
};
}, []);
function openDiscussion(id: number) {
navigate(`/discussions/${id}`);
}
const openDiscussion = (id: number) => navigate(`/discussions/${id}`);
function goSignIn() {
window.location.assign(forgejoSignInUrl(pathname));
}
function goCourses() {
navigate("/courses");
}
const goCourses = () => navigate("/courses");
function goDiscussions() {
navigate("/discussions");
}
const goDiscussions = () => navigate("/discussions");
function goActivity() {
navigate("/activity");
}
const goActivity = () => navigate("/activity");
function openCourse(course: CourseCard) {
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
}
const openPost = (post: PostCard) => navigate(postPath(post));
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
navigate(
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
@ -1248,9 +1349,7 @@ export default function App() {
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
}
function goHome() {
navigate("/");
}
const goHome = () => navigate("/");
async function signOut() {
await fetch("/api/auth/session", {
@ -1273,6 +1372,7 @@ export default function App() {
data={data}
pathname={pathname}
onOpenCourse={openCourse}
onOpenPost={openPost}
onOpenLesson={openLesson}
onOpenDiscussion={openDiscussion}
onOpenRoute={navigate}

View file

@ -268,7 +268,8 @@ textarea {
padding: 1rem;
}
.course-card-button {
.course-card-button,
.post-card-button {
width: 100%;
cursor: pointer;
text-align: left;
@ -276,7 +277,9 @@ textarea {
}
.course-card-button:hover,
.course-card-button:focus-visible {
.course-card-button:focus-visible,
.post-card-button:hover,
.post-card-button:focus-visible {
background: var(--panel-hover);
outline: none;
}

View file

@ -50,9 +50,16 @@ export interface CourseLesson {
export interface PostCard {
title: string;
owner: string;
name: string;
repo: string;
slug: string;
kind: string;
summary: string;
path: string;
file_path: string;
html_url: string;
body: string;
updated_at: string;
}