Complete Forgejo discussion MVP
This commit is contained in:
parent
d84a885fdb
commit
51706d2d11
17 changed files with 1708 additions and 127 deletions
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from "preact/hooks";
|
|||
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
|
||||
import type {
|
||||
AuthState,
|
||||
ContentAsset,
|
||||
CourseCard,
|
||||
CourseChapter,
|
||||
CourseLesson,
|
||||
|
|
@ -49,6 +50,26 @@ function parseDiscussionRoute(pathname: string): number | null {
|
|||
return Number.isFinite(issueId) ? issueId : null;
|
||||
}
|
||||
|
||||
function parseDiscussionRepoRoute(
|
||||
pathname: string,
|
||||
): { owner: string; repo: string; number: number } | null {
|
||||
const match = normalizePathname(pathname).match(/^\/discussions\/([^/]+)\/([^/]+)\/(\d+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issueNumber = Number(match[3]);
|
||||
if (!Number.isFinite(issueNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
owner: decodeURIComponent(match[1]),
|
||||
repo: decodeURIComponent(match[2]),
|
||||
number: issueNumber,
|
||||
};
|
||||
}
|
||||
|
||||
function isSignInRoute(pathname: string): boolean {
|
||||
return normalizePathname(pathname) === "/signin";
|
||||
}
|
||||
|
|
@ -179,6 +200,17 @@ function findLessonByRoute(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function findDiscussionByRoute(
|
||||
discussions: DiscussionCard[],
|
||||
route: { owner: string; repo: string; number: number },
|
||||
): DiscussionCard | undefined {
|
||||
const routeRepo = `${normalizeRouteKey(route.owner)}/${normalizeRouteKey(route.repo)}`;
|
||||
return discussions.find(
|
||||
(discussion) =>
|
||||
normalizeRouteKey(discussion.repo) === routeRepo && discussion.number === route.number,
|
||||
);
|
||||
}
|
||||
|
||||
function usePathname() {
|
||||
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
|
||||
|
||||
|
|
@ -424,7 +456,7 @@ function EventItem(props: { event: EventCard }) {
|
|||
|
||||
function DiscussionPreviewItem(props: {
|
||||
discussion: DiscussionCard;
|
||||
onOpenDiscussion: (id: number) => void;
|
||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
||||
}) {
|
||||
const { discussion, onOpenDiscussion } = props;
|
||||
|
||||
|
|
@ -433,7 +465,7 @@ function DiscussionPreviewItem(props: {
|
|||
type="button"
|
||||
className="discussion-preview-card"
|
||||
onClick={() => {
|
||||
onOpenDiscussion(discussion.id);
|
||||
onOpenDiscussion(discussion);
|
||||
}}
|
||||
>
|
||||
<h3>{discussion.title}</h3>
|
||||
|
|
@ -500,6 +532,42 @@ async function postDiscussionReply(
|
|||
return (await response.json()) as DiscussionReply;
|
||||
}
|
||||
|
||||
interface DiscussionCreateContext {
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
contextPath?: string;
|
||||
contextUrl?: string;
|
||||
contextTitle?: string;
|
||||
}
|
||||
|
||||
async function postDiscussion(
|
||||
title: string,
|
||||
body: string,
|
||||
context?: DiscussionCreateContext,
|
||||
): Promise<DiscussionCard> {
|
||||
const response = await fetch("/api/discussions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
owner: context?.owner,
|
||||
repo: context?.repo,
|
||||
context_path: context?.contextPath,
|
||||
context_url: context?.contextUrl,
|
||||
context_title: context?.contextTitle,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await responseError(response, `Discussion creation failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as DiscussionCard;
|
||||
}
|
||||
|
||||
function ComposeBox(props: {
|
||||
discussion: DiscussionCard;
|
||||
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
|
||||
|
|
@ -578,6 +646,193 @@ function ComposeBox(props: {
|
|||
);
|
||||
}
|
||||
|
||||
function DiscussionCreateBox(props: {
|
||||
auth: AuthState;
|
||||
context?: DiscussionCreateContext;
|
||||
titlePlaceholder: string;
|
||||
bodyPlaceholder: string;
|
||||
submitLabel: string;
|
||||
onGoSignIn: () => void;
|
||||
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
||||
}) {
|
||||
const {
|
||||
auth,
|
||||
context,
|
||||
titlePlaceholder,
|
||||
bodyPlaceholder,
|
||||
submitLabel,
|
||||
onGoSignIn,
|
||||
onDiscussionCreated,
|
||||
} = props;
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedBody = body.trim();
|
||||
const canCreate = canUseInteractiveAuth(auth);
|
||||
|
||||
async function submitDiscussion(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canCreate) {
|
||||
setError("Sign in before starting a discussion.");
|
||||
return;
|
||||
}
|
||||
if (!trimmedTitle || !trimmedBody || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const discussion = await postDiscussion(trimmedTitle, trimmedBody, context);
|
||||
setTitle("");
|
||||
setBody("");
|
||||
onDiscussionCreated(discussion);
|
||||
} catch (createError) {
|
||||
const message =
|
||||
createError instanceof Error ? createError.message : "Discussion could not be created.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="compose-box" onSubmit={submitDiscussion}>
|
||||
{!canCreate ? (
|
||||
<div className="signin-callout">
|
||||
<p>
|
||||
{auth.authenticated
|
||||
? "Discussion creation is unavailable for this session."
|
||||
: "Sign in before starting a discussion."}
|
||||
</p>
|
||||
{!auth.authenticated ? (
|
||||
<button type="button" className="secondary-button" onClick={onGoSignIn}>
|
||||
Sign in
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
className="token-input"
|
||||
placeholder={titlePlaceholder}
|
||||
value={title}
|
||||
disabled={!canCreate}
|
||||
onInput={(event) => {
|
||||
setTitle(event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
className="compose-input"
|
||||
placeholder={bodyPlaceholder}
|
||||
value={body}
|
||||
disabled={!canCreate}
|
||||
onInput={(event) => {
|
||||
setBody(event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<div className="compose-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="compose-button"
|
||||
disabled={!canCreate || !trimmedTitle || !trimmedBody || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="compose-error">{error}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetsPanel(props: { assets: ContentAsset[] }) {
|
||||
if (props.assets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<header className="subsection-header">
|
||||
<h2>Downloads</h2>
|
||||
</header>
|
||||
<div className="asset-list">
|
||||
{props.assets.map((asset) => (
|
||||
<a
|
||||
key={asset.path}
|
||||
className="asset-link"
|
||||
href={asset.download_url || asset.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>{asset.name}</span>
|
||||
<span className="meta-line">{asset.path}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function lessonPath(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson): string {
|
||||
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`;
|
||||
}
|
||||
|
||||
function absoluteSiteUrl(path: string): string {
|
||||
return new URL(path, window.location.origin).toString();
|
||||
}
|
||||
|
||||
function discussionMatchesContent(
|
||||
discussion: DiscussionCard,
|
||||
routePath: string,
|
||||
contentPath: string,
|
||||
): boolean {
|
||||
const normalizedRoute = normalizeRouteKey(routePath);
|
||||
const normalizedContentPath = normalizeRouteKey(contentPath);
|
||||
return (discussion.links || []).some((link) => {
|
||||
const linkPath = normalizeRouteKey(link.path || "");
|
||||
const linkContentPath = normalizeRouteKey(link.content_path || "");
|
||||
return (
|
||||
linkPath === normalizedRoute ||
|
||||
linkContentPath === normalizedContentPath ||
|
||||
normalizedContentPath.startsWith(`${linkContentPath}/`)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function RelatedDiscussionsPanel(props: {
|
||||
discussions: DiscussionCard[];
|
||||
routePath: string;
|
||||
contentPath: string;
|
||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
||||
}) {
|
||||
const relatedDiscussions = props.discussions.filter((discussion) =>
|
||||
discussionMatchesContent(discussion, props.routePath, props.contentPath),
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<header className="subsection-header">
|
||||
<h2>Related discussions</h2>
|
||||
<p className="meta-line">{relatedDiscussions.length}</p>
|
||||
</header>
|
||||
{relatedDiscussions.length > 0 ? (
|
||||
<div className="reply-list">
|
||||
{relatedDiscussions.map((discussion) => (
|
||||
<DiscussionPreviewItem
|
||||
key={discussion.id}
|
||||
discussion={discussion}
|
||||
onOpenDiscussion={props.onOpenDiscussion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState copy="No linked discussions yet." />
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CoursePage(props: {
|
||||
course: CourseCard;
|
||||
onGoHome: () => void;
|
||||
|
|
@ -644,10 +899,26 @@ function LessonPage(props: {
|
|||
course: CourseCard;
|
||||
chapter: CourseChapter;
|
||||
lesson: CourseLesson;
|
||||
auth: AuthState;
|
||||
discussions: DiscussionCard[];
|
||||
onGoCourse: () => void;
|
||||
onGoSignIn: () => void;
|
||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
||||
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
||||
}) {
|
||||
const { course, chapter, lesson, onGoCourse } = props;
|
||||
const {
|
||||
course,
|
||||
chapter,
|
||||
lesson,
|
||||
auth,
|
||||
discussions,
|
||||
onGoCourse,
|
||||
onGoSignIn,
|
||||
onOpenDiscussion,
|
||||
onDiscussionCreated,
|
||||
} = props;
|
||||
const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title);
|
||||
const routePath = lessonPath(course, chapter, lesson);
|
||||
|
||||
return (
|
||||
<section className="thread-view">
|
||||
|
|
@ -670,18 +941,60 @@ function LessonPage(props: {
|
|||
<h2>Lesson</h2>
|
||||
</header>
|
||||
{lessonBody ? (
|
||||
<MarkdownContent markdown={lessonBody} className="lesson-body" />
|
||||
<MarkdownContent
|
||||
markdown={lessonBody}
|
||||
className="lesson-body"
|
||||
baseUrl={lesson.raw_base_url}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState copy="This lesson file is empty or could not be read from Forgejo." />
|
||||
)}
|
||||
</article>
|
||||
|
||||
<AssetsPanel assets={lesson.assets} />
|
||||
|
||||
<RelatedDiscussionsPanel
|
||||
discussions={discussions}
|
||||
routePath={routePath}
|
||||
contentPath={lesson.path}
|
||||
onOpenDiscussion={onOpenDiscussion}
|
||||
/>
|
||||
|
||||
<article className="panel">
|
||||
<header className="subsection-header">
|
||||
<h2>Start a lesson discussion</h2>
|
||||
</header>
|
||||
<DiscussionCreateBox
|
||||
auth={auth}
|
||||
context={{
|
||||
owner: course.owner,
|
||||
repo: course.name,
|
||||
contextPath: lesson.file_path || lesson.path,
|
||||
contextUrl: absoluteSiteUrl(routePath),
|
||||
contextTitle: lesson.title,
|
||||
}}
|
||||
titlePlaceholder="What should this lesson discussion be called?"
|
||||
bodyPlaceholder="Describe the blocker, question, or project note."
|
||||
submitLabel="Start discussion"
|
||||
onGoSignIn={onGoSignIn}
|
||||
onDiscussionCreated={onDiscussionCreated}
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PostPage(props: { post: PostCard }) {
|
||||
const { post } = props;
|
||||
function PostPage(props: {
|
||||
post: PostCard;
|
||||
auth: AuthState;
|
||||
discussions: DiscussionCard[];
|
||||
onGoSignIn: () => void;
|
||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
||||
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
||||
}) {
|
||||
const { post, auth, discussions, onGoSignIn, onOpenDiscussion, onDiscussionCreated } = props;
|
||||
const postBody = stripLeadingTitleHeading(post.body, post.title);
|
||||
const routePath = postPath(post);
|
||||
|
||||
return (
|
||||
<section className="thread-view">
|
||||
|
|
@ -700,11 +1013,45 @@ function PostPage(props: { post: PostCard }) {
|
|||
<h2>Post</h2>
|
||||
</header>
|
||||
{postBody ? (
|
||||
<MarkdownContent markdown={postBody} className="thread-copy" />
|
||||
<MarkdownContent
|
||||
markdown={postBody}
|
||||
className="thread-copy"
|
||||
baseUrl={post.raw_base_url}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState copy="This post file is empty or could not be read from Forgejo." />
|
||||
)}
|
||||
</article>
|
||||
|
||||
<AssetsPanel assets={post.assets} />
|
||||
|
||||
<RelatedDiscussionsPanel
|
||||
discussions={discussions}
|
||||
routePath={routePath}
|
||||
contentPath={post.path}
|
||||
onOpenDiscussion={onOpenDiscussion}
|
||||
/>
|
||||
|
||||
<article className="panel">
|
||||
<header className="subsection-header">
|
||||
<h2>Start a post discussion</h2>
|
||||
</header>
|
||||
<DiscussionCreateBox
|
||||
auth={auth}
|
||||
context={{
|
||||
owner: post.owner,
|
||||
repo: post.name,
|
||||
contextPath: post.file_path || post.path,
|
||||
contextUrl: absoluteSiteUrl(routePath),
|
||||
contextTitle: post.title,
|
||||
}}
|
||||
titlePlaceholder="What should this post discussion be called?"
|
||||
bodyPlaceholder="Share a question, project note, or blocker."
|
||||
submitLabel="Start discussion"
|
||||
onGoSignIn={onGoSignIn}
|
||||
onDiscussionCreated={onDiscussionCreated}
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -785,12 +1132,42 @@ function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: Course
|
|||
);
|
||||
}
|
||||
|
||||
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
|
||||
const { data, onOpenDiscussion } = props;
|
||||
function DiscussionsView(props: {
|
||||
data: PrototypeData;
|
||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
||||
onGoSignIn: () => void;
|
||||
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
||||
showComposer?: boolean;
|
||||
}) {
|
||||
const { data, onOpenDiscussion, onGoSignIn, onDiscussionCreated, showComposer = true } = props;
|
||||
const generalDiscussionConfigured =
|
||||
data.discussion_settings?.general_discussion_configured ?? false;
|
||||
|
||||
return (
|
||||
<section className="page-section">
|
||||
<SectionHeader title="Discussions" />
|
||||
{showComposer && generalDiscussionConfigured ? (
|
||||
<article className="discussion-create-panel">
|
||||
<header className="subsection-header">
|
||||
<h2>Start a discussion</h2>
|
||||
</header>
|
||||
<DiscussionCreateBox
|
||||
auth={data.auth}
|
||||
titlePlaceholder="What should the discussion be called?"
|
||||
bodyPlaceholder="Share a project update, blocker, or question."
|
||||
submitLabel="Start discussion"
|
||||
onGoSignIn={onGoSignIn}
|
||||
onDiscussionCreated={onDiscussionCreated}
|
||||
/>
|
||||
</article>
|
||||
) : null}
|
||||
{showComposer && !generalDiscussionConfigured ? (
|
||||
<article className="discussion-create-panel">
|
||||
<p className="muted-copy">
|
||||
General discussion creation needs a configured public org repo.
|
||||
</p>
|
||||
</article>
|
||||
) : null}
|
||||
{data.recent_discussions.length > 0 ? (
|
||||
<div className="stack">
|
||||
{data.recent_discussions.map((discussion) => (
|
||||
|
|
@ -812,9 +1189,12 @@ function HomeView(props: {
|
|||
data: PrototypeData;
|
||||
onOpenCourse: (course: CourseCard) => void;
|
||||
onOpenPost: (post: PostCard) => void;
|
||||
onOpenDiscussion: (id: number) => void;
|
||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
||||
onGoSignIn: () => void;
|
||||
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
||||
}) {
|
||||
const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props;
|
||||
const { data, onOpenCourse, onOpenPost, onOpenDiscussion, onGoSignIn, onDiscussionCreated } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -856,7 +1236,13 @@ function HomeView(props: {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<DiscussionsView data={data} onOpenDiscussion={onOpenDiscussion} />
|
||||
<DiscussionsView
|
||||
data={data}
|
||||
onOpenDiscussion={onOpenDiscussion}
|
||||
onGoSignIn={onGoSignIn}
|
||||
onDiscussionCreated={onDiscussionCreated}
|
||||
showComposer={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -912,6 +1298,22 @@ async function fetchPrototypeData(signal?: AbortSignal): Promise<PrototypeData>
|
|||
return (await response.json()) as PrototypeData;
|
||||
}
|
||||
|
||||
async function fetchDiscussionDetail(
|
||||
route: { owner: string; repo: string; number: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<DiscussionCard> {
|
||||
const response = await fetch(
|
||||
`/api/discussions/${encodeURIComponent(route.owner)}/${encodeURIComponent(route.repo)}/${route.number}`,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discussion request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as DiscussionCard;
|
||||
}
|
||||
|
||||
function appendDiscussionReply(
|
||||
currentData: PrototypeData | null,
|
||||
discussionId: number,
|
||||
|
|
@ -938,6 +1340,23 @@ function appendDiscussionReply(
|
|||
};
|
||||
}
|
||||
|
||||
function prependDiscussion(
|
||||
currentData: PrototypeData | null,
|
||||
discussion: DiscussionCard,
|
||||
): PrototypeData | null {
|
||||
if (!currentData) {
|
||||
return currentData;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
recent_discussions: [
|
||||
discussion,
|
||||
...currentData.recent_discussions.filter((entry) => entry.id !== discussion.id),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -959,6 +1378,15 @@ function postPath(post: PostCard): string {
|
|||
return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`;
|
||||
}
|
||||
|
||||
function discussionPath(discussion: DiscussionCard): string {
|
||||
const repo = repoParts(discussion.repo);
|
||||
if (!repo || discussion.number < 1) {
|
||||
return `/discussions/${discussion.id}`;
|
||||
}
|
||||
|
||||
return `/discussions/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/${discussion.number}`;
|
||||
}
|
||||
|
||||
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
|
||||
const activities: ActivityEntry[] = [];
|
||||
|
||||
|
|
@ -992,7 +1420,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
|
|||
title: `Discussion updated: ${discussion.title}`,
|
||||
detail: `${discussion.repo} · ${discussion.replies} replies`,
|
||||
timestamp: discussion.updated_at,
|
||||
route: `/discussions/${discussion.id}`,
|
||||
route: discussionPath(discussion),
|
||||
});
|
||||
|
||||
for (const reply of discussion.comments) {
|
||||
|
|
@ -1001,7 +1429,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
|
|||
title: `${reply.author} replied`,
|
||||
detail: discussion.title,
|
||||
timestamp: reply.created_at,
|
||||
route: `/discussions/${discussion.id}`,
|
||||
route: discussionPath(discussion),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1066,10 +1494,11 @@ interface AppContentProps {
|
|||
onOpenCourse: (course: CourseCard) => void;
|
||||
onOpenPost: (post: PostCard) => void;
|
||||
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
|
||||
onOpenDiscussion: (id: number) => void;
|
||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
||||
onOpenRoute: (route: string) => void;
|
||||
onGoSignIn: () => void;
|
||||
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
|
||||
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
||||
onGoHome: () => void;
|
||||
onGoCourses: () => void;
|
||||
onGoDiscussions: () => void;
|
||||
|
|
@ -1087,7 +1516,16 @@ function PostRouteView(
|
|||
);
|
||||
}
|
||||
|
||||
return <PostPage post={selectedPost} />;
|
||||
return (
|
||||
<PostPage
|
||||
post={selectedPost}
|
||||
auth={props.data.auth}
|
||||
discussions={props.data.recent_discussions}
|
||||
onGoSignIn={props.onGoSignIn}
|
||||
onOpenDiscussion={props.onOpenDiscussion}
|
||||
onDiscussionCreated={props.onDiscussionCreated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonRouteView(
|
||||
|
|
@ -1130,9 +1568,14 @@ function LessonRouteView(
|
|||
course={selectedCourse}
|
||||
chapter={selectedLesson.chapter}
|
||||
lesson={selectedLesson.lesson}
|
||||
auth={props.data.auth}
|
||||
discussions={props.data.recent_discussions}
|
||||
onGoCourse={() => {
|
||||
props.onOpenCourse(selectedCourse);
|
||||
}}
|
||||
onGoSignIn={props.onGoSignIn}
|
||||
onOpenDiscussion={props.onOpenDiscussion}
|
||||
onDiscussionCreated={props.onDiscussionCreated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1185,6 +1628,66 @@ function DiscussionRouteView(props: AppContentProps & { discussionId: number })
|
|||
);
|
||||
}
|
||||
|
||||
function DiscussionRepoRouteView(
|
||||
props: AppContentProps & { route: { owner: string; repo: string; number: number } },
|
||||
) {
|
||||
const indexedDiscussion = findDiscussionByRoute(props.data.recent_discussions, props.route);
|
||||
const [loadedDiscussion, setLoadedDiscussion] = useState<DiscussionCard | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const selectedDiscussion = indexedDiscussion || loadedDiscussion;
|
||||
|
||||
useEffect(() => {
|
||||
if (indexedDiscussion) {
|
||||
setLoadedDiscussion(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
fetchDiscussionDetail(props.route, controller.signal)
|
||||
.then((discussion) => {
|
||||
setLoadedDiscussion(discussion);
|
||||
setError(null);
|
||||
})
|
||||
.catch((fetchError) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setError(fetchError instanceof Error ? fetchError.message : "Discussion did not load.");
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [indexedDiscussion, props.route.owner, props.route.repo, props.route.number]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="page-message">
|
||||
<h1>Discussion did not load.</h1>
|
||||
<p className="muted-copy">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedDiscussion) {
|
||||
return (
|
||||
<section className="page-message">
|
||||
<h1>Loading discussion.</h1>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DiscussionPage
|
||||
discussion={selectedDiscussion}
|
||||
auth={props.data.auth}
|
||||
onGoHome={props.onGoDiscussions}
|
||||
onGoSignIn={props.onGoSignIn}
|
||||
onReplyCreated={props.onReplyCreated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent(props: AppContentProps) {
|
||||
if (isSignInRoute(props.pathname)) {
|
||||
return <SignInPage auth={props.data.auth} />;
|
||||
|
|
@ -1199,7 +1702,19 @@ function AppContent(props: AppContentProps) {
|
|||
}
|
||||
|
||||
if (isDiscussionsIndexRoute(props.pathname)) {
|
||||
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />;
|
||||
return (
|
||||
<DiscussionsView
|
||||
data={props.data}
|
||||
onOpenDiscussion={props.onOpenDiscussion}
|
||||
onGoSignIn={props.onGoSignIn}
|
||||
onDiscussionCreated={props.onDiscussionCreated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const discussionRepoRoute = parseDiscussionRepoRoute(props.pathname);
|
||||
if (discussionRepoRoute !== null) {
|
||||
return <DiscussionRepoRouteView {...props} route={discussionRepoRoute} />;
|
||||
}
|
||||
|
||||
const postRoute = parsePostRoute(props.pathname);
|
||||
|
|
@ -1225,6 +1740,8 @@ function AppContent(props: AppContentProps) {
|
|||
onOpenCourse={props.onOpenCourse}
|
||||
onOpenPost={props.onOpenPost}
|
||||
onOpenDiscussion={props.onOpenDiscussion}
|
||||
onGoSignIn={props.onGoSignIn}
|
||||
onDiscussionCreated={props.onDiscussionCreated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1261,6 +1778,7 @@ function LoadedApp(
|
|||
onOpenRoute={props.onOpenRoute}
|
||||
onGoSignIn={props.onGoSignIn}
|
||||
onReplyCreated={props.onReplyCreated}
|
||||
onDiscussionCreated={props.onDiscussionCreated}
|
||||
onGoHome={props.onGoHome}
|
||||
onGoCourses={props.onGoCourses}
|
||||
onGoDiscussions={props.onGoDiscussions}
|
||||
|
|
@ -1281,10 +1799,9 @@ function AppStatusPage(props: { title: string; copy?: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
function usePrototypeData() {
|
||||
const [data, setData] = useState<PrototypeData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { pathname, navigate } = usePathname();
|
||||
|
||||
async function loadPrototype(signal?: AbortSignal) {
|
||||
try {
|
||||
|
|
@ -1310,7 +1827,24 @@ export default function App() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const openDiscussion = (id: number) => navigate(`/discussions/${id}`);
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("/api/events/stream");
|
||||
eventSource.addEventListener("content-updated", () => {
|
||||
loadPrototype();
|
||||
});
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, setData, error, loadPrototype };
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { data, setData, error, loadPrototype } = usePrototypeData();
|
||||
const { pathname, navigate } = usePathname();
|
||||
|
||||
const openDiscussion = (discussion: DiscussionCard) => navigate(discussionPath(discussion));
|
||||
|
||||
function goSignIn() {
|
||||
window.location.assign(forgejoSignInUrl(pathname));
|
||||
|
|
@ -1329,15 +1863,18 @@ export default function App() {
|
|||
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)}`,
|
||||
);
|
||||
navigate(lessonPath(course, chapter, lesson));
|
||||
}
|
||||
|
||||
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
|
||||
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
|
||||
}
|
||||
|
||||
function addDiscussion(discussion: DiscussionCard) {
|
||||
setData((currentData) => prependDiscussion(currentData, discussion));
|
||||
navigate(discussionPath(discussion));
|
||||
}
|
||||
|
||||
const goHome = () => navigate("/");
|
||||
|
||||
async function signOut() {
|
||||
|
|
@ -1367,6 +1904,7 @@ export default function App() {
|
|||
onOpenRoute={navigate}
|
||||
onGoSignIn={goSignIn}
|
||||
onReplyCreated={addReplyToDiscussion}
|
||||
onDiscussionCreated={addDiscussion}
|
||||
onGoHome={goHome}
|
||||
onGoCourses={goCourses}
|
||||
onGoDiscussions={goDiscussions}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ function escapeHtml(value: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function normalizeLinkTarget(value: string): string | null {
|
||||
function normalizeLinkTarget(value: string, baseUrl?: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
|
|
@ -31,7 +31,7 @@ function normalizeLinkTarget(value: string): string | null {
|
|||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const url = new URL(trimmed, baseUrl || undefined);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return escapeHtml(url.toString());
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ function normalizeLinkTarget(value: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function renderInline(markdown: string): string {
|
||||
function renderInline(markdown: string, baseUrl?: string): string {
|
||||
const codeTokens: string[] = [];
|
||||
let rendered = escapeHtml(markdown);
|
||||
|
||||
|
|
@ -51,10 +51,21 @@ function renderInline(markdown: string): string {
|
|||
codeTokens.push(`<code>${code}</code>`);
|
||||
return token;
|
||||
});
|
||||
rendered = rendered.replace(
|
||||
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
|
||||
(_match, label: string, href: string) => {
|
||||
const safeHref = normalizeLinkTarget(href, baseUrl);
|
||||
if (!safeHref) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `<img src="${safeHref}" alt="${label}" loading="lazy" />`;
|
||||
},
|
||||
);
|
||||
rendered = rendered.replace(
|
||||
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
|
||||
(_match, label: string, href: string) => {
|
||||
const safeHref = normalizeLinkTarget(href);
|
||||
const safeHref = normalizeLinkTarget(href, baseUrl);
|
||||
if (!safeHref) {
|
||||
return label;
|
||||
}
|
||||
|
|
@ -83,12 +94,12 @@ function createParserState(): ParserState {
|
|||
};
|
||||
}
|
||||
|
||||
function flushParagraph(state: ParserState) {
|
||||
function flushParagraph(state: ParserState, baseUrl?: string) {
|
||||
if (state.paragraphLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "))}</p>`);
|
||||
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "), baseUrl)}</p>`);
|
||||
state.paragraphLines.length = 0;
|
||||
}
|
||||
|
||||
|
|
@ -104,13 +115,13 @@ function flushList(state: ParserState) {
|
|||
state.listType = null;
|
||||
}
|
||||
|
||||
function flushBlockquote(state: ParserState) {
|
||||
function flushBlockquote(state: ParserState, baseUrl?: string) {
|
||||
if (state.blockquoteLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.output.push(
|
||||
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "))}</p></blockquote>`,
|
||||
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "), baseUrl)}</p></blockquote>`,
|
||||
);
|
||||
state.blockquoteLines.length = 0;
|
||||
}
|
||||
|
|
@ -131,10 +142,10 @@ function flushCodeBlock(state: ParserState) {
|
|||
state.codeLines.length = 0;
|
||||
}
|
||||
|
||||
function flushInlineBlocks(state: ParserState) {
|
||||
flushParagraph(state);
|
||||
function flushInlineBlocks(state: ParserState, baseUrl?: string) {
|
||||
flushParagraph(state, baseUrl);
|
||||
flushList(state);
|
||||
flushBlockquote(state);
|
||||
flushBlockquote(state, baseUrl);
|
||||
}
|
||||
|
||||
function handleCodeBlockLine(state: ParserState, line: string): boolean {
|
||||
|
|
@ -151,124 +162,129 @@ function handleCodeBlockLine(state: ParserState, line: string): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
function handleFenceStart(state: ParserState, line: string): boolean {
|
||||
function handleFenceStart(state: ParserState, line: string, baseUrl?: string): boolean {
|
||||
if (!line.trim().startsWith("```")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushInlineBlocks(state);
|
||||
flushInlineBlocks(state, baseUrl);
|
||||
state.inCodeBlock = true;
|
||||
state.codeLanguage = line.trim().slice(3).trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleBlankLine(state: ParserState, line: string): boolean {
|
||||
function handleBlankLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
||||
if (line.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushInlineBlocks(state);
|
||||
flushInlineBlocks(state, baseUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleHeadingLine(state: ParserState, line: string): boolean {
|
||||
function handleHeadingLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (!headingMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushInlineBlocks(state);
|
||||
flushInlineBlocks(state, baseUrl);
|
||||
const level = headingMatch[1].length;
|
||||
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim())}</h${level}>`);
|
||||
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim(), baseUrl)}</h${level}>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleRuleLine(state: ParserState, line: string): boolean {
|
||||
function handleRuleLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
||||
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushInlineBlocks(state);
|
||||
flushInlineBlocks(state, baseUrl);
|
||||
state.output.push("<hr />");
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleListLine(state: ParserState, line: string, listType: ListType): boolean {
|
||||
function handleListLine(
|
||||
state: ParserState,
|
||||
line: string,
|
||||
listType: ListType,
|
||||
baseUrl?: string,
|
||||
): boolean {
|
||||
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
|
||||
const match = line.match(pattern);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushParagraph(state);
|
||||
flushBlockquote(state);
|
||||
flushParagraph(state, baseUrl);
|
||||
flushBlockquote(state, baseUrl);
|
||||
if (state.listType !== listType) {
|
||||
flushList(state);
|
||||
state.listType = listType;
|
||||
}
|
||||
state.listItems.push(renderInline(match[1].trim()));
|
||||
state.listItems.push(renderInline(match[1].trim(), baseUrl));
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleBlockquoteLine(state: ParserState, line: string): boolean {
|
||||
function handleBlockquoteLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
||||
const match = line.match(/^>\s?(.*)$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushParagraph(state);
|
||||
flushParagraph(state, baseUrl);
|
||||
flushList(state);
|
||||
state.blockquoteLines.push(match[1].trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleParagraphLine(state: ParserState, line: string) {
|
||||
function handleParagraphLine(state: ParserState, line: string, baseUrl?: string) {
|
||||
flushList(state);
|
||||
flushBlockquote(state);
|
||||
flushBlockquote(state, baseUrl);
|
||||
state.paragraphLines.push(line.trim());
|
||||
}
|
||||
|
||||
function processMarkdownLine(state: ParserState, line: string) {
|
||||
function processMarkdownLine(state: ParserState, line: string, baseUrl?: string) {
|
||||
if (handleCodeBlockLine(state, line)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleFenceStart(state, line)) {
|
||||
if (handleFenceStart(state, line, baseUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleBlankLine(state, line)) {
|
||||
if (handleBlankLine(state, line, baseUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleHeadingLine(state, line)) {
|
||||
if (handleHeadingLine(state, line, baseUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleRuleLine(state, line)) {
|
||||
if (handleRuleLine(state, line, baseUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleListLine(state, line, "ul") || handleListLine(state, line, "ol")) {
|
||||
if (handleListLine(state, line, "ul", baseUrl) || handleListLine(state, line, "ol", baseUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleBlockquoteLine(state, line)) {
|
||||
if (handleBlockquoteLine(state, line, baseUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleParagraphLine(state, line);
|
||||
handleParagraphLine(state, line, baseUrl);
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown: string): string {
|
||||
function markdownToHtml(markdown: string, baseUrl?: string): string {
|
||||
const state = createParserState();
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
processMarkdownLine(state, line);
|
||||
processMarkdownLine(state, line, baseUrl);
|
||||
}
|
||||
|
||||
flushInlineBlocks(state);
|
||||
flushInlineBlocks(state, baseUrl);
|
||||
flushCodeBlock(state);
|
||||
return state.output.join("");
|
||||
}
|
||||
|
|
@ -288,8 +304,8 @@ export function stripLeadingTitleHeading(markdown: string, title: string): strin
|
|||
return markdown;
|
||||
}
|
||||
|
||||
export function MarkdownContent(props: { markdown: string; className?: string }) {
|
||||
const html = markdownToHtml(props.markdown);
|
||||
export function MarkdownContent(props: { markdown: string; className?: string; baseUrl?: string }) {
|
||||
const html = markdownToHtml(props.markdown, props.baseUrl);
|
||||
const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
|
||||
|
||||
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
|
|
|
|||
|
|
@ -296,6 +296,14 @@ textarea {
|
|||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.discussion-create-panel {
|
||||
margin-bottom: 1rem;
|
||||
border: 0.0625rem solid var(--accent-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.discussion-preview-card {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
|
|
@ -546,6 +554,12 @@ textarea {
|
|||
color: var(--accent);
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
border: 0.0625rem solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
width: 100%;
|
||||
height: 0.0625rem;
|
||||
|
|
@ -559,6 +573,32 @@ textarea {
|
|||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.asset-link {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
border: 0.0625rem solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
color: inherit;
|
||||
background: var(--card);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.asset-link:hover,
|
||||
.asset-link:focus-visible {
|
||||
background: var(--panel-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.asset-link .meta-line {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.signin-page {
|
||||
display: grid;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export interface AuthState {
|
|||
oauth_configured: boolean;
|
||||
}
|
||||
|
||||
export interface ContentAsset {
|
||||
name: string;
|
||||
path: string;
|
||||
html_url: string;
|
||||
download_url: string;
|
||||
}
|
||||
|
||||
export interface CourseCard {
|
||||
title: string;
|
||||
owner: string;
|
||||
|
|
@ -44,6 +51,8 @@ export interface CourseLesson {
|
|||
path: string;
|
||||
file_path: string;
|
||||
html_url: string;
|
||||
raw_base_url: string;
|
||||
assets: ContentAsset[];
|
||||
summary: string;
|
||||
body: string;
|
||||
}
|
||||
|
|
@ -59,6 +68,8 @@ export interface PostCard {
|
|||
path: string;
|
||||
file_path: string;
|
||||
html_url: string;
|
||||
raw_base_url: string;
|
||||
assets: ContentAsset[];
|
||||
body: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -85,6 +96,7 @@ export interface DiscussionCard {
|
|||
html_url: string;
|
||||
labels: string[];
|
||||
comments: DiscussionReply[];
|
||||
links: DiscussionLink[];
|
||||
}
|
||||
|
||||
export interface DiscussionReply {
|
||||
|
|
@ -96,9 +108,25 @@ export interface DiscussionReply {
|
|||
html_url: string;
|
||||
}
|
||||
|
||||
export interface DiscussionLink {
|
||||
kind: "post" | "lesson";
|
||||
path: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
slug?: string;
|
||||
chapter?: string;
|
||||
lesson?: string;
|
||||
content_path: string;
|
||||
}
|
||||
|
||||
export interface DiscussionSettings {
|
||||
general_discussion_configured: boolean;
|
||||
}
|
||||
|
||||
export interface PrototypeData {
|
||||
hero: HeroData;
|
||||
auth: AuthState;
|
||||
discussion_settings: DiscussionSettings;
|
||||
source_of_truth: SourceOfTruthCard[];
|
||||
featured_courses: CourseCard[];
|
||||
recent_posts: PostCard[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue