sdlc-hub/web/src/modules/wiki/PageView.tsx

84 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, type ComponentProps, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import { Link } from "react-router";
import remarkGfm from "remark-gfm";
import { Notice, IconExternal, IconPaperclip } from "../../ui/index.ts";
import { fmtStamp } from "../../ui/format.ts";
import { useWikiPage } from "./api/queries.ts";
import { useWikiDownload } from "./download.ts";
import { wikiUrlTransform } from "./urlTransform.ts";
const PLUGINS = [remarkGfm];
const safeUrl = (u: string | null) => (u && /^https?:\/\//i.test(u.trim()) ? u : null);
// копия начинается заголовком «# название» — он повторяет заголовок страницы и в тексте не нужен
function withoutTitle(md: string, title: string | null): string {
const m = /^# ([^\n]*)\n+/.exec(md);
return m && title !== null && m[1].trim() === title.split(/\s+/).filter(Boolean).join(" ") ? md.slice(m[0].length) : md;
}
// заголовки текста страницы идут ниже заголовка страницы (h2): # → h3, ## → h3, ### → h4 … не глубже h6
const heading = (n: number) => {
const Tag = `h${Math.min(6, Math.max(3, n + 1))}` as "h3";
return function Heading({ children }: ComponentProps<"h1">) { return <Tag className={`wiki-md__h wiki-md__h--${n}`}>{children}</Tag>; };
};
const HEADINGS = { h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
const decode = (s: string) => { try { return decodeURIComponent(s); } catch { return s; } };
// страница вики: заголовок, дата синхронизации, «открыть в Eva» и Markdown (таблицы GFM, без сырого HTML)
export function PageView({ project, code, hrefOf }: { project: string; code: string; hrefOf(code: string): string }) {
const page = useWikiPage(project, code);
const dl = useWikiDownload(project, code);
const atts = page.data?.attachments;
const failed = useMemo(() => new Map((atts ?? []).filter((a) => a.state === "failed" && a.error).map((a) => [String(a.id), a.error!])), [atts]);
const components = useMemo<Components>(() => ({
...HEADINGS,
a({ href, children }: ComponentProps<"a">) {
if (!href) return <>{children}</>;
if (/^page:/i.test(href)) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
if (/^attachment:/i.test(href)) return <AttachmentLink id={decode(href.slice(11))} dl={dl} failed={failed}>{children}</AttachmentLink>;
if (/^https?:/i.test(href)) return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
return <a href={href}>{children}</a>;
},
// картинки по внешним адресам не загружаются (трекинг, чужие ресурсы): виден только текст
img: ({ alt }: ComponentProps<"img">) => <span className="wiki-img">[изображение{alt ? `: ${alt}` : ""}]</span>,
table: ({ children }: ComponentProps<"table">) => <div className="wiki-table"><table>{children}</table></div>,
}), [hrefOf, dl, failed]);
if (page.isPending) return <p className="wiki-hint">Загрузка страницы…</p>;
if (page.isError) return <Notice tone="error">{page.error.message}</Notice>;
const d = page.data;
const eva = safeUrl(d.url);
return (
<article className="wiki-page" aria-labelledby="wiki-page-title">
<header className="wiki-page__head">
<h2 id="wiki-page-title" className="wiki-page__title">{d.title || d.code}</h2>
<p className="wiki-page__meta">
<span className="wiki-page__code">{d.code}</span>
{d.synced_at && <span>синхронизировано {fmtStamp(d.synced_at)}</span>}
{eva && <a href={eva} target="_blank" rel="noopener noreferrer" className="wiki-page__eva"><IconExternal size={16} />открыть в Eva</a>}
</p>
</header>
<div className="wiki-md">
<Markdown remarkPlugins={PLUGINS} urlTransform={wikiUrlTransform} components={components}>{withoutTitle(d.markdown, d.title)}</Markdown>
</div>
</article>
);
}
// ссылка attachment:<id>: запрос на скачивание, ожидание, сохранение файла; ошибка — текстом рядом
// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика
function AttachmentLink({ id, dl, failed, children }: {
id: string; dl: ReturnType<typeof useWikiDownload>; failed: Map<string, string>; children?: ReactNode;
}) {
const st = dl.status(id);
const busy = st.busy, error = st.error ?? (busy ? null : failed.get(id) ?? null);
return (
<>
<button type="button" className="wiki-att" disabled={busy} onClick={() => dl.start(id)}>
<IconPaperclip size={16} />{children}{busy && <span className="wiki-att__busy"> Загружается…</span>}
</button>
{error && <span className="wiki-att__error" role="alert"> {error}</span>}
</>
);
}