Преглед изворни кода

feat(dashboard-v6): add workspace/tipitaka/cs-para route

Migrate the v4 article/cs-para viewer to dashboard-v6:
- add fetchCSParaNav + csParaLoader API
- add CsPara editor feature (nav-cs-para + TypePali para range)
- add workspace/tipitaka/cs-para page and route
- register locale titles and recent navigation
visuddhinanda пре 2 дана
родитељ
комит
0fec9e141c

+ 28 - 0
dashboard-v6/src/api/article.ts

@@ -596,3 +596,31 @@ export const fetchPageNav = (pageId: string): Promise<IPageNavResponse> => {
   }-${pageParam[3]}`;
   return get<IPageNavResponse>(url);
 };
+
+/**
+ * cs-para 导航
+ *
+ * csParaId 形如 `book_para_page`,例如 `169_3_64`。
+ * GET /api/v2/nav-cs-para/{csParaId}
+ */
+export const fetchCSParaNav = (
+  csParaId: string
+): Promise<ICSParaNavResponse> => {
+  return get<ICSParaNavResponse>(`/api/v2/nav-cs-para/${csParaId}`);
+};
+
+export async function csParaLoader({ params }: LoaderFunctionArgs) {
+  const id = params.id;
+
+  if (!id) {
+    throw new Response("Missing cs-para id", { status: 400 });
+  }
+
+  const res = await fetchCSParaNav(id);
+
+  if (!res.ok) {
+    throw new Response("cs-para not found", { status: 404 });
+  }
+
+  return { title: res.data.curr.content };
+}

+ 186 - 0
dashboard-v6/src/features/editor/CsPara.tsx

@@ -0,0 +1,186 @@
+// ─────────────────────────────────────────────
+// Props
+// ─────────────────────────────────────────────
+
+import { useEffect, useState } from "react";
+import { useLocation } from "react-router";
+import type {
+  ArticleMode,
+  ArticleType,
+  ICSParaNavData,
+} from "../../api/article";
+import { fetchCSParaNav } from "../../api/article";
+import TypePali, {
+  type ISearchParams,
+} from "../../components/article/TypePali";
+import NavigateButton from "../../components/article/components/NavigateButton";
+import ArticleSkeleton from "../../components/article/components/ArticleSkeleton";
+import ErrorResult from "../../components/general/ErrorResult";
+import Editor from "../../components/editor";
+import PaliTextToc from "../../components/tipitaka/PaliTextToc";
+import { useSaveRecent } from "../../hooks/useSaveRecent";
+import type { TTarget } from "../../types";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { HttpError } from "../../request";
+
+export interface CsParaEditorProps {
+  /** cs-para id,形如 `book_para_page`,例如 `169_3_64` */
+  articleId?: string;
+  mode?: ArticleMode;
+  channelId?: string | null;
+
+  // ── 路由事件回调(由 page 层处理导航)──
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: TTarget,
+    param?: ISearchParams[]
+  ) => void;
+}
+
+// ─────────────────────────────────────────────
+// Component
+// ─────────────────────────────────────────────
+
+export default function CsParaEditor({
+  articleId,
+  mode = "read",
+  channelId,
+  onArticleChange,
+}: CsParaEditorProps) {
+  const [nav, setNav] = useState<ICSParaNavData>();
+  const [errorCode, setErrorCode] = useState<number>();
+  const [errorMessage, setErrorMessage] = useState<string>();
+  const currUser = useAppSelector(currentUser);
+  const { save } = useSaveRecent();
+  const { search } = useLocation();
+
+  // 记录最近访问
+  useEffect(() => {
+    if (!currUser?.id || !articleId) return;
+    const paramObj = search
+      ? Object.fromEntries(new URLSearchParams(search))
+      : undefined;
+    save({
+      type: "cs-para",
+      article_id: articleId,
+      param: JSON.stringify(paramObj),
+    });
+  }, [currUser?.id, articleId, search, save]);
+
+  // 拉取 cs-para 导航数据,推导实际要渲染的段落区间
+  useEffect(() => {
+    if (typeof articleId === "undefined") {
+      console.error("articleId 不能为空");
+      return;
+    }
+    const pageParam = articleId.split("_");
+    if (pageParam.length !== 3) {
+      console.error("pageParam 必须为三个");
+      return;
+    }
+    setNav(undefined);
+    setErrorCode(undefined);
+    setErrorMessage(undefined);
+    fetchCSParaNav(articleId)
+      .then((json) => {
+        if (json.ok) {
+          setNav(json.data);
+        } else {
+          setErrorCode(500);
+          setErrorMessage(json.message);
+        }
+      })
+      .catch((e) => {
+        console.error(e);
+        if (e instanceof HttpError) {
+          setErrorCode(e.status);
+          setErrorMessage(e.message);
+        } else {
+          setErrorCode(500);
+        }
+      });
+  }, [articleId]);
+
+  // 由导航数据推导实际要渲染的段落区间 id(book-start-end)
+  const paraId = nav
+    ? `${nav.curr.book}-${nav.curr.start}-${nav.end}`
+    : undefined;
+  const book = nav?.curr.book;
+  const para = nav?.curr.start;
+
+  const goto = (
+    offset: number,
+    event: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => {
+    if (!articleId) return;
+    const pageParam = articleId.split("_");
+    if (pageParam.length !== 3) return;
+    const nextPage = parseInt(pageParam[2]) + offset;
+    if (nextPage < 0) return;
+    const id = `${pageParam[0]}_${pageParam[1]}_${nextPage}`;
+    const target = event.ctrlKey || event.metaKey ? "_blank" : "_self";
+    onArticleChange?.("cs-para", id, target);
+  };
+
+  if (errorCode !== undefined) {
+    return <ErrorResult code={errorCode} message={errorMessage} />;
+  }
+
+  return (
+    <Editor
+      sidebarTitle="recent scan"
+      sidebar={
+        <PaliTextToc
+          book={book}
+          para={para}
+          onSelect={(selected) => {
+            if (selected) {
+              onArticleChange?.("chapter", selected[0], "_self");
+            }
+          }}
+        />
+      }
+      articleId={paraId}
+      articleType="para"
+      channelId={channelId}
+      onChannelSelect={(selected) => {
+        if (articleId) {
+          const channelParams = [
+            {
+              key: "channel",
+              value: selected.map((item) => item.id).join("_"),
+            },
+          ];
+          console.debug("onChannelSelect", channelParams);
+          onArticleChange?.("cs-para", articleId, "_self", channelParams);
+        }
+      }}
+    >
+      {({ expandButton }) =>
+        nav ? (
+          <>
+            <TypePali
+              id={paraId}
+              type="para"
+              mode={mode}
+              channelId={channelId}
+              headerExtra={expandButton}
+              hideNav
+              onArticleChange={onArticleChange}
+            />
+            <NavigateButton
+              prevTitle={nav.prev?.content.slice(0, 10)}
+              nextTitle={nav.next?.content.slice(0, 10)}
+              onPrev={(event) => goto(-1, event)}
+              onNext={(event) => goto(1, event)}
+            />
+          </>
+        ) : (
+          <ArticleSkeleton />
+        )
+      }
+    </Editor>
+  );
+}

+ 1 - 0
dashboard-v6/src/locales/en-US/pages.ts

@@ -11,6 +11,7 @@ const items = {
   "pages.task.project.title": "Project",
   "pages.tipitaka.chapter.title": "Chapter",
   "pages.tipitaka.para.title": "Paragraph",
+  "pages.tipitaka.cs-para.title": "CS Paragraph",
 };
 
 export default items;

+ 1 - 0
dashboard-v6/src/locales/zh-Hans/pages.ts

@@ -11,6 +11,7 @@ const items = {
   "pages.task.project.title": "项目",
   "pages.tipitaka.chapter.title": "章节",
   "pages.tipitaka.para.title": "段落",
+  "pages.tipitaka.cs-para.title": "对照页",
 };
 
 export default items;

+ 1 - 1
dashboard-v6/src/pages/workspace/home.tsx

@@ -40,7 +40,7 @@ export default function WorkspaceHome() {
           <RecentList
             items={recentItems}
             onClick={(type, id) => {
-              if (type === "chapter") {
+              if (type === "chapter" || type === "para" || type === "cs-para") {
                 navigate(`/workspace/tipitaka/${type}/${id}`);
               }
             }}

+ 56 - 0
dashboard-v6/src/pages/workspace/tipitaka/cs-para.tsx

@@ -0,0 +1,56 @@
+import {
+  useLocation,
+  useMatches,
+  useNavigate,
+  useParams,
+  useSearchParams,
+} from "react-router";
+import { useIntl } from "react-intl";
+import type { ArticleMode } from "../../../api/article";
+import CsParaEditor from "../../../features/editor/CsPara";
+
+const Widget = () => {
+  const { id } = useParams();
+  const [searchParams] = useSearchParams();
+  const navigate = useNavigate();
+  const { search } = useLocation();
+  const intl = useIntl();
+  const matches = useMatches() as {
+    data?: { title?: string; name?: string; word?: string };
+  }[];
+  const data = [...matches].reverse().find((m) => m.data)?.data;
+  const name = data?.title ?? data?.name ?? data?.word;
+  const prefix = intl.formatMessage({ id: "pages.tipitaka.cs-para.title" });
+
+  const mode = searchParams.get("mode") ?? "read";
+  const channelId = searchParams.get("channel");
+
+  return (
+    <>
+      <title>{name ? `${prefix}-${name}` : prefix}</title>
+      <CsParaEditor
+        articleId={id}
+        mode={mode as ArticleMode}
+        channelId={channelId}
+        onArticleChange={(type, id, target, param) => {
+          const url = `workspace/tipitaka/${type}/${id}`;
+          const urlSearch =
+            param && param.length > 0
+              ? "?" +
+                param.map((item) => `${item.key}=${item.value}`).join("&")
+              : search;
+          if (target === "_blank") {
+            window.open(
+              `${window.location.origin}${import.meta.env.BASE_URL}${url}${urlSearch}`,
+              "_blank"
+            );
+          } else {
+            navigate(`/${url}${urlSearch}`);
+          }
+        }}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 15 - 0
dashboard-v6/src/routes/tipitakaRoutes.ts

@@ -2,6 +2,7 @@
 import { lazy } from "react";
 import type { RouteObject } from "react-router";
 import { chapterLoader, paraLoader } from "../api/pali-text";
+import { csParaLoader } from "../api/article";
 
 const WorkspaceTipitaka = lazy(
   () => import("../pages/workspace/tipitaka/bypath")
@@ -12,6 +13,9 @@ const WorkspaceTipitakaChapter = lazy(
 const WorkspaceTipitakaPara = lazy(
   () => import("../pages/workspace/tipitaka/para")
 );
+const WorkspaceTipitakaCsPara = lazy(
+  () => import("../pages/workspace/tipitaka/cs-para")
+);
 
 const tipitakaRoutes: RouteObject[] = [
   {
@@ -63,6 +67,17 @@ const tipitakaRoutes: RouteObject[] = [
           },
         ],
       },
+      {
+        path: "cs-para",
+        children: [
+          {
+            path: ":id",
+            Component: WorkspaceTipitakaCsPara,
+            loader: csParaLoader,
+            handle: { id: "workspace.tipitaka.cs-para", crumb: "cs-para" },
+          },
+        ],
+      },
     ],
   },
 ];