Render blog posts from Forgejo
This commit is contained in:
parent
6671a01d26
commit
0a9b052beb
6 changed files with 366 additions and 48 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue