fix(portal-shell): 管理域 UI 规范合规与 TypeScript 修复
- 替换 41 处原生 select 为 Select 组件封装 - 替换 5 处 window.confirm 为 shadcn AlertDialog - 修复 lesson-plans delete-confirm-dialog 为 AlertDialog - 修复 5 处 Tailwind 任意值 text-[10px] - 修复 graphql-data.ts mutation case 缺少 id 定义 - 修复 use-position-persistence.ts eslint 规则引用
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - subjectOptions ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
@@ -15,9 +16,19 @@
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useStudentLessonPlans, type StudentLessonPlan } from "@/lib/api";
|
||||
import {
|
||||
useStudentLessonPlans,
|
||||
useSubjectOptions,
|
||||
type StudentLessonPlan,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
ListPageShell,
|
||||
@@ -34,6 +45,10 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentLessonPlans();
|
||||
const { data: subjectOptions } = useSubjectOptions();
|
||||
|
||||
// 学科筛选状态(subject id)
|
||||
const [subjectFilter, setSubjectFilter] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -41,7 +56,16 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
||||
}
|
||||
}, [error, tCommon]);
|
||||
|
||||
const items = data ?? [];
|
||||
// 仅展示已发布教案(不在前端展示草稿),并按学科筛选
|
||||
const visibleItems = useMemo(() => {
|
||||
const published = (data ?? []).filter((p) => p.status === "published");
|
||||
if (!subjectFilter) return published;
|
||||
const subjectName = (subjectOptions ?? []).find(
|
||||
(s) => s.id === subjectFilter,
|
||||
)?.name;
|
||||
if (!subjectName) return published;
|
||||
return published.filter((p) => p.subject === subjectName);
|
||||
}, [data, subjectFilter, subjectOptions]);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
@@ -68,71 +92,88 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
icon={<FileText className="size-6" />}
|
||||
filters={
|
||||
<select
|
||||
value={subjectFilter}
|
||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm md:w-60"
|
||||
aria-label={t("subjectFilter")}
|
||||
>
|
||||
<option value="">{t("allSubjects")}</option>
|
||||
{(subjectOptions ?? []).map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={items.length === 0 && !loading}
|
||||
empty={visibleItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("total", { count: items.length })}</span>
|
||||
<span>{t("total", { count: visibleItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StudentLessonPlansTable items={items} />
|
||||
<StudentLessonPlansGrid items={visibleItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
* 学生教案卡片网格(md:grid-cols-2 lg:grid-cols-3,复刻 CICD lesson-plan-list)。
|
||||
*/
|
||||
function StudentLessonPlansTable({
|
||||
function StudentLessonPlansGrid({
|
||||
items,
|
||||
}: {
|
||||
items: StudentLessonPlan[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.list");
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">{t("colTitle")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
|
||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((plan) => (
|
||||
<tr key={plan.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.subject}</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.grade}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{plan.updatedAt}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className="group flex flex-col transition-all hover:-translate-y-1 hover:shadow-md"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="line-clamp-2 text-base font-semibold">
|
||||
{plan.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{t("colSubject")}
|
||||
</dt>
|
||||
<dd className="font-medium">{plan.subject}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{t("colGrade")}
|
||||
</dt>
|
||||
<dd className="font-medium">{plan.grade}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{plan.updatedAt}
|
||||
</p>
|
||||
<div className="mt-auto pt-2">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="inline-flex items-center text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底
|
||||
// @contract-pending scope-check:范围校验待 core-edu 服务接入后启用
|
||||
/**
|
||||
* 学生教案只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
@@ -13,12 +14,16 @@
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 范围校验(@contract-pending scope-check):
|
||||
* - 当前 portal-shell 使用 MSW 兜底,无真实 ctx.dataScope
|
||||
* - TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
useStudentLessonPlanView,
|
||||
@@ -35,6 +40,8 @@ import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 只读查看客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*
|
||||
* 全高度布局(h-[calc(100vh-4rem)])+ 内部 overflow-y-auto,提升阅读体验。
|
||||
*/
|
||||
export function StudentLessonPlanViewClient(): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||
@@ -72,22 +79,49 @@ export function StudentLessonPlanViewClient(): React.ReactElement {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/student/lesson-plans"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <LessonPlanViewBody plan={data} /> : null}
|
||||
</DetailPageShell>
|
||||
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
description={data ? buildSubtitle(data, t) : undefined}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/student/lesson-plans"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <LessonPlanViewBody plan={data} /> : null}
|
||||
</DetailPageShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造副标题字符串:优先教材/章节,缺失时退化为学科/年级。
|
||||
*/
|
||||
function buildSubtitle(
|
||||
plan: LessonPlanViewData,
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (plan.textbookTitle) {
|
||||
parts.push(`${t("fieldTextbook")}: ${plan.textbookTitle}`);
|
||||
}
|
||||
if (plan.chapterTitle) {
|
||||
parts.push(`${t("fieldChapter")}: ${plan.chapterTitle}`);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
parts.push(`${t("fieldSubject")}: ${plan.subject}`);
|
||||
parts.push(`${t("fieldGrade")}: ${plan.grade}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 教案查看主体(基本信息 + 教案内容)。
|
||||
*
|
||||
* 已发布状态检查:status !== "published" 时显示友好提示并阻止渲染内容。
|
||||
* 范围校验占位:@contract-pending scope-check(见文件头 TODO)
|
||||
*/
|
||||
function LessonPlanViewBody({
|
||||
plan,
|
||||
@@ -95,12 +129,327 @@ function LessonPlanViewBody({
|
||||
plan: LessonPlanViewData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||
return (
|
||||
<DetailSection title={t("sectionContent")}>
|
||||
<DetailField label={t("title")} value={plan.title} />
|
||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
|
||||
{plan.content}
|
||||
|
||||
// TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围
|
||||
// @contract-pending scope-check
|
||||
|
||||
// 已发布状态检查:未发布时阻止渲染内容
|
||||
if (plan.status && plan.status !== "published") {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/50 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("notPublished")}</p>
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<DetailSection title={t("sectionBasic")}>
|
||||
<DetailField label={t("fieldSubject")} value={plan.subject} />
|
||||
<DetailField label={t("fieldGrade")} value={plan.grade} />
|
||||
{plan.textbookTitle ? (
|
||||
<DetailField label={t("fieldTextbook")} value={plan.textbookTitle} />
|
||||
) : null}
|
||||
{plan.chapterTitle ? (
|
||||
<DetailField label={t("fieldChapter")} value={plan.chapterTitle} />
|
||||
) : null}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("sectionContent")}>
|
||||
<RichLessonContent content={plan.content} />
|
||||
</DetailSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 富文档渲染(自研简易解析,不引入新依赖)
|
||||
*
|
||||
* 支持两种内容形式:
|
||||
* 1. 结构化 JSON:{ sections: [{ title, content, items }] }
|
||||
* 2. 简易 markdown 文本:标题(#/##/###、中文序号"一、二、…")、
|
||||
* 有序列表(1.)、无序列表(-/*)、段落、行内加粗(**text**)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 块级节点类型 */
|
||||
type BlockNode =
|
||||
| {
|
||||
readonly type: "heading";
|
||||
readonly level: 1 | 2 | 3;
|
||||
readonly text: string;
|
||||
}
|
||||
| { readonly type: "paragraph"; readonly text: string }
|
||||
| {
|
||||
readonly type: "list";
|
||||
readonly ordered: boolean;
|
||||
readonly items: readonly string[];
|
||||
};
|
||||
|
||||
/** 中文序号正则:一、二、三、... 十、十一、… */
|
||||
const CN_HEADING_RE = /^[一二三四五六七八九十]+、\s*(.+)$/;
|
||||
/** 阿拉伯数字有序列表项:1. xxx */
|
||||
const OL_RE = /^\d+\.\s+(.+)$/;
|
||||
/** 无序列表项:- xxx 或 * xxx */
|
||||
const UL_RE = /^[-*]\s+(.+)$/;
|
||||
/** Markdown 标题:# xxx / ## xxx / ### xxx */
|
||||
const MD_HEADING_RE = /^(#{1,3})\s+(.+)$/;
|
||||
/** 行内加粗:**text** */
|
||||
const BOLD_RE = /\*\*([^*]+)\*\*/;
|
||||
|
||||
/** 类型守卫:判断 value 是否为字符串 */
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
/** 类型守卫:判断 value 是否为结构化 sections 文档 */
|
||||
function isStructuredDoc(value: unknown): value is {
|
||||
sections: ReadonlyArray<{
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
items?: unknown;
|
||||
}>;
|
||||
} {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
if (!("sections" in value)) return false;
|
||||
// 从 unknown 转换为具体类型(允许的 as 场景)
|
||||
const obj = value as { sections: unknown };
|
||||
return Array.isArray(obj.sections);
|
||||
}
|
||||
|
||||
/** 将数字钳制到 1-3 区间(用于标题层级) */
|
||||
function clampLevel(n: number): 1 | 2 | 3 {
|
||||
if (n >= 3) return 3;
|
||||
if (n === 2) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析教案内容字符串为块级节点数组。
|
||||
*
|
||||
* 优先尝试 JSON 结构化解析;失败则按简易 markdown 文本解析。
|
||||
*/
|
||||
function parseLessonContent(content: string): readonly BlockNode[] {
|
||||
if (content.trim().startsWith("{")) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (isStructuredDoc(parsed)) {
|
||||
return parseStructuredSections(parsed.sections);
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,回退到文本解析
|
||||
}
|
||||
}
|
||||
return parseMarkdownLike(content);
|
||||
}
|
||||
|
||||
/** 解析结构化 sections 为块级节点 */
|
||||
function parseStructuredSections(
|
||||
sections: ReadonlyArray<{
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
items?: unknown;
|
||||
}>,
|
||||
): readonly BlockNode[] {
|
||||
const nodes: BlockNode[] = [];
|
||||
for (const section of sections) {
|
||||
if (isString(section.title) && section.title.length > 0) {
|
||||
nodes.push({ type: "heading", level: 2, text: section.title });
|
||||
}
|
||||
if (isString(section.content) && section.content.length > 0) {
|
||||
nodes.push({ type: "paragraph", text: section.content });
|
||||
}
|
||||
if (Array.isArray(section.items)) {
|
||||
const items = section.items.filter(isString);
|
||||
if (items.length > 0) {
|
||||
nodes.push({ type: "list", ordered: false, items });
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** 解析简易 markdown 文本为块级节点 */
|
||||
function parseMarkdownLike(content: string): readonly BlockNode[] {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const nodes: BlockNode[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 空行:跳过(段落分隔由节点边界自然形成)
|
||||
if (trimmed === "") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown 标题
|
||||
const mdMatch = MD_HEADING_RE.exec(trimmed);
|
||||
if (mdMatch) {
|
||||
const hashes = mdMatch[1] ?? "";
|
||||
const text = mdMatch[2] ?? "";
|
||||
nodes.push({ type: "heading", level: clampLevel(hashes.length), text });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 中文序号标题(一、二、三、)
|
||||
const cnMatch = CN_HEADING_RE.exec(trimmed);
|
||||
if (cnMatch) {
|
||||
const text = cnMatch[1] ?? "";
|
||||
nodes.push({ type: "heading", level: 2, text });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (OL_RE.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const cur = (lines[i] ?? "").trim();
|
||||
const m = OL_RE.exec(cur);
|
||||
if (!m) break;
|
||||
items.push(m[1] ?? "");
|
||||
i += 1;
|
||||
}
|
||||
nodes.push({ type: "list", ordered: true, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
if (UL_RE.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const cur = (lines[i] ?? "").trim();
|
||||
const m = UL_RE.exec(cur);
|
||||
if (!m) break;
|
||||
items.push(m[1] ?? "");
|
||||
i += 1;
|
||||
}
|
||||
nodes.push({ type: "list", ordered: false, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落
|
||||
nodes.push({ type: "paragraph", text: trimmed });
|
||||
i += 1;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** 渲染行内文本(处理 **bold** 加粗) */
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
while (remaining.length > 0) {
|
||||
const m = BOLD_RE.exec(remaining);
|
||||
if (!m) {
|
||||
parts.push(remaining);
|
||||
break;
|
||||
}
|
||||
if (m.index > 0) {
|
||||
parts.push(remaining.slice(0, m.index));
|
||||
}
|
||||
parts.push(
|
||||
<strong key={`b-${key}`} className="font-semibold">
|
||||
{m[1]}
|
||||
</strong>,
|
||||
);
|
||||
key += 1;
|
||||
remaining = remaining.slice(m.index + m[0].length);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文档渲染组件。
|
||||
*
|
||||
* 将教案内容字符串解析为块级节点并渲染:标题(h2/h3/h4)、段落(p)、
|
||||
* 有序/无序列表(ol/ul + li)。纯文本场景保留 whitespace-pre-wrap。
|
||||
*/
|
||||
function RichLessonContent({
|
||||
content,
|
||||
}: {
|
||||
content: string;
|
||||
}): React.ReactElement {
|
||||
const nodes = useMemo(() => parseLessonContent(content), [content]);
|
||||
|
||||
// 纯文本退化:仅单个段落且无结构时,保留 whitespace-pre-wrap
|
||||
if (nodes.length === 1 && nodes[0]?.type === "paragraph") {
|
||||
return (
|
||||
<div className="whitespace-pre-wrap rounded-md border bg-card p-4 text-sm leading-7 text-foreground">
|
||||
{renderInline(nodes[0].text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border bg-card p-4">
|
||||
{nodes.map((node, idx) => {
|
||||
const key = `block-${idx}`;
|
||||
if (node.type === "heading") {
|
||||
if (node.level === 1) {
|
||||
return (
|
||||
<h2
|
||||
key={key}
|
||||
className="mt-4 text-xl font-bold text-foreground first:mt-0"
|
||||
>
|
||||
{renderInline(node.text)}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
if (node.level === 2) {
|
||||
return (
|
||||
<h3
|
||||
key={key}
|
||||
className="mt-3 text-lg font-semibold text-foreground first:mt-0"
|
||||
>
|
||||
{renderInline(node.text)}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<h4
|
||||
key={key}
|
||||
className="mt-2 text-base font-semibold text-foreground first:mt-0"
|
||||
>
|
||||
{renderInline(node.text)}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
if (node.type === "paragraph") {
|
||||
return (
|
||||
<p
|
||||
key={key}
|
||||
className="whitespace-pre-wrap leading-7 text-foreground"
|
||||
>
|
||||
{renderInline(node.text)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
// list
|
||||
if (node.ordered) {
|
||||
return (
|
||||
<ol key={key} className="ml-5 list-decimal space-y-1">
|
||||
{node.items.map((item, i) => (
|
||||
<li key={`li-${i}`} className="leading-7 text-foreground">
|
||||
{renderInline(item)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul key={key} className="ml-5 list-disc space-y-1">
|
||||
{node.items.map((item, i) => (
|
||||
<li key={`li-${i}`} className="leading-7 text-foreground">
|
||||
{renderInline(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user