Files
Aislo/ui_template/ui_template_help.ts
T
eomsangdonandClaude Opus 5.5 02f15c3d6a feat(가이드): 538-2 [?] = 넓은 모달 한 가이드 · 차례 묶음 → 페이지 → 기능 · 그림 · 주의/참고 · B01 [?]
- [?] = openModal wide · 왼쪽 차례(임도 설계 B → 안정성평가 E → 예정노선 D · 지금 페이지 펼침) ·
  오른쪽 = 모든 페이지 이어 그림 · 처음 = 보이는 data-help-topic 절 → 지금 페이지 절 → 맨 앞 · B05↔B06 짝 걷음
- 그리기: 덩어리마다 원문 줄(data-src) · `![설명](이름.webp)` → figure(loading=lazy · ?v=판) ·
  없는 그림 = 관리자 「그림 없음 — 더블클릭해 지정」 / 일반 「그림 준비 중」 · `> 주의:` · `> 참고:` 상자 ·
  `[글](B06/00_설명서)` → 모달 안 이동 · 페이지를 안 넘기면(B01 지식 채팅) 옛 출력 그대로
- GET /api/help 에 페이지마다 그림 판 · GET /api/help/{page}/img/{name}(이름 규칙 · no-cache) ·
  옛 GET /api/help/{page} 걷음(모달이 한 가이드로 바뀜)
- B01 대시보드 머리 역할 배지 왼쪽에 [?]
- 시험 test_538_help_guide.py(변환 글 · 그림 404 · admin 칸) + helper_538_help_render.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:47:38 +09:00

368 lines
15 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.
/* =============================================================================
* ui_template_help.ts
* [?] 사용자 가이드(54-2 · 538) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 넓은 모달(왼쪽 차례 · 오른쪽 가이드 통째).
* 글 = `A05_EduDetail/guide/<페이지>/*.md` — 서버가 열 때마다 읽음(`GET /api/help` 한 번에 모든 페이지).
* 차례 = 묶음(임도 설계 B → 안정성평가 E → 예정노선 D) → 페이지 → 기능 · 지금 페이지 펼침.
* 처음 자리 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 절(제 페이지 먼저 · 없으면 모든 페이지) ·
* 없으면 지금 페이지(#/b05-… → B05) 절 · 그것도 없으면 맨 앞.
* 시스템 관리자 = 더블클릭 바로 고침(`ui_template_help_edit.ts`).
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.
* [<] [>](요구 280-2) = [?] 바로 뒤 · 단계 묶음(B05~B09 · D02~D04 · E02~E05 · 529-1) 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 →
* 진행 단계 틀(`createWorkflowLayout`)이 이웃 단계 단추를 누름(저장 · 막힘은 각 페이지 `onStepClick`).
* ========================================================================== */
import "./ui_template_help.css";
import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend";
import { createButton, el } from "./ui_template_elements";
import { openModal } from "./ui_template_modal";
export interface HelpItem {
key: string;
title: string;
text: string;
version: string;
}
export interface HelpPage {
page: string;
title: string;
items: HelpItem[];
/** 그림 이름 → 판(고친 시각) */
images: Record<string, string>;
}
export interface HelpGuide {
pages: HelpPage[];
admin: boolean;
}
const TEXT = {
button: "?",
tip: "사용자 가이드",
title: "사용자 가이드",
close: "닫기",
empty: "가이드 글 없음",
imgMissing: "그림 없음 — 더블클릭해 지정",
imgPending: "그림 준비 중",
};
const STEP_TEXT = { prev: "<", next: ">", prevTip: "이전 단계", nextTip: "다음 단계" };
/** 차례 묶음 — 페이지 머리 글자 */
const GROUPS = [
{ label: "임도 설계", prefix: "B" },
{ label: "안정성평가", prefix: "E" },
{ label: "예정노선", prefix: "D" },
];
const IMG_RE = /^[A-Za-z0-9_-]+\.(webp|png|jpg)$/;
/** [<] [>] 를 붙이는 주소 묶음 — 묶음 안에서만 오감(b09-print · B03 · B04 · M01 · M02 · D01 · E01 뺌) */
const STEP_NAV_GROUPS: ReadonlyArray<readonly string[]> = [
[
ROUTES.B05_PROFILE,
ROUTES.B06_SECTION,
ROUTES.B07_DESIGN_DETAIL,
ROUTES.B08_QUANTITY,
ROUTES.B09_ESTIMATION,
],
PLAN_STEP_ROUTES,
STAB_STEP_ROUTES,
];
/** 가이드 폴더 이름 = 주소 머리(#/b05-… → B05 · #/d02-… → D02) */
function currentPage(): string {
const head = /^#\/([bde]0\d)/i.exec(location.hash)?.[1];
return head ? head.toUpperCase() : "";
}
function escapeHtml(text: string): string {
return text.replace(
/[&<>"]/g,
(c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`,
);
}
/** 가이드 안 링크 `[글](B06/00_설명서)` · `[글](B06)` — 파일 이름 속 괄호 한 겹까지 */
const GOTO_RE = /\[([^\]]+)\]\(([BDE]\d{2}(?:\/(?:[^()]|\([^()]*\))+)?)\)/g;
function inline(text: string, links = false): string {
const html = escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`(.+?)`/g, "<code>$1</code>");
return links
? html.replace(GOTO_RE, '<a href="#" class="ui-help-goto" data-help-goto="$2">$1</a>')
: html;
}
/** 페이지를 넘긴 때만 — 덩어리 원문 줄(`data-src`) · 그림 · 주의/참고 상자 · 가이드 안 링크 */
export interface HelpRenderContext {
page: string;
images: Record<string, string>;
admin: boolean;
}
function figure(alt: string, name: string, ctx: HelpRenderContext): string {
const version = ctx.images[name];
const img = version
? `<img src="${API_BASE_URL}/help/${ctx.page}/img/${name}?v=${version}" alt="${escapeHtml(alt)}" loading="lazy">`
: `<div class="ui-help-figure__empty">${ctx.admin ? TEXT.imgMissing : TEXT.imgPending}</div>`;
const caption = alt ? `<figcaption>${inline(alt)}</figcaption>` : "";
return `<figure class="ui-help-figure" data-img="${name}">${img}${caption}</figure>`;
}
function note(lines: string[], src: string): string {
const body = lines.map((line) => line.replace(/^>\s?/, ""));
const head = /^(주의|참고)\s*[::]\s*(.*)$/.exec(body[0]);
if (head) body[0] = head[2];
const kind = head?.[1] === "주의" ? "warn" : "info";
return `<aside class="ui-help-note ui-help-note--${kind}"${src}><strong>${head?.[1] ?? "참고"}</strong> ${body.map((line) => inline(line, true)).join("<br>")}</aside>`;
}
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 표 · 문단 · **굵게** · `코드` (+ ctx 때 그림 · 상자 · 링크) */
export function renderHelpMarkdown(text: string, ctx?: HelpRenderContext): string {
const out: string[] = [];
let list: "ul" | "ol" | null = null;
let para: string[] = [];
let start = 0;
let end = 0;
let listAt = 0;
let listFrom = 0;
const lines = text.split(/\r?\n/);
const src = (from: number, to: number): string => (ctx ? ` data-src="${from}-${to}"` : "");
const cells = (line: string): string[] =>
line
.trim()
.replace(/^\||\|$/g, "")
.split("|")
.map((cell) => cell.trim());
const separator = (line: string): boolean =>
cells(line).every((cell) => /^:?-{3,}:?$/.test(cell));
const flush = (): void => {
if (para.length)
out.push(`<p${src(start, end)}>${para.map((l) => inline(l, !!ctx)).join("<br>")}</p>`);
para = [];
if (list) out.push(`</${list}>`);
list = null;
};
for (let index = 0; index < lines.length; index += 1) {
const raw = lines[index];
const line = raw.trim();
if (line.includes("|") && lines[index + 1]?.includes("|") && separator(lines[index + 1])) {
flush();
const from = index;
const headers = cells(line);
const rows: string[][] = [];
index += 2;
while (index < lines.length && lines[index].includes("|")) rows.push(cells(lines[index++]));
index -= 1;
out.push(
`<div class="ui-markdown-table"${src(from, index)}><table><thead><tr>${headers.map((cell) => `<th>${inline(cell, !!ctx)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, cell) => `<td>${inline(row[cell] ?? "", !!ctx)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`,
);
continue;
}
const head = /^(#{1,4})\s+(.*)$/.exec(line);
const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line);
const image = ctx && /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line);
if (!line) flush();
else if (ctx && line.startsWith(">")) {
flush();
const from = index;
while (lines[index + 1]?.trim().startsWith(">")) index += 1;
const quote = lines.slice(from, index + 1).map((l) => l.trim());
out.push(note(quote, src(from, index)));
} else if (image && IMG_RE.test(image[2])) {
flush();
out.push(figure(image[1], image[2], ctx).replace("<figure", `<figure${src(index, index)}`));
} else if (head) {
flush();
const level = head[1].length + 1;
out.push(`<h${level}${src(index, index)}>${inline(head[2], !!ctx)}</h${level}>`);
} else if (item) {
const kind = item[1] ? "ul" : "ol";
if (para.length || list !== kind) flush();
if (!list) {
listAt = out.length;
listFrom = index;
out.push("");
}
list = kind;
out.push(`<li>${inline(item[2], !!ctx)}</li>`);
out[listAt] = `<${kind}${src(listFrom, index)}>`;
} else {
if (list) flush();
if (!para.length) start = index;
end = index;
para.push(line);
}
}
flush();
return out.join("");
}
function visibleTopics(): string[] {
const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document;
return [...panel.querySelectorAll<HTMLElement>("[data-help-topic]")]
.filter((node) => node.offsetParent !== null)
.map((node) => node.dataset.helpTopic ?? "");
}
async function fetchGuide(): Promise<HelpGuide> {
const response = await fetch(`${API_BASE_URL}/help`, {
credentials: "include",
cache: "no-store",
});
return response.ok ? ((await response.json()) as HelpGuide) : { pages: [], admin: false };
}
/** 한 기능 절 — 자리 이름 `B05/00_설명서` */
export function renderHelpItem(page: HelpPage, item: HelpItem, admin: boolean): string {
const ctx = { page: page.page, images: page.images, admin };
return renderHelpMarkdown(item.text, ctx);
}
function anchorOf(page: string, key?: string): string {
return key ? `${page}/${key}` : page;
}
/** 처음 자리 — 보이는 주제(제 페이지 먼저 · 없으면 모든 페이지) → 지금 페이지 → 맨 앞 */
function firstAnchor(pages: HelpPage[]): string {
const page = currentPage();
const topics = visibleTopics().reverse();
const own = pages.find((p) => p.page === page);
for (const scope of own ? [[own], pages] : [pages])
for (const key of topics) {
const hit = scope.find((p) => p.items.some((item) => item.key === key));
if (hit) return anchorOf(hit.page, key);
}
return own ? page : (pages[0]?.page ?? "");
}
async function openHelp(): Promise<void> {
const guide = await fetchGuide();
const pages = GROUPS.flatMap((g) => guide.pages.filter((p) => p.page.startsWith(g.prefix)));
const start = firstAnchor(pages);
let flush = async (): Promise<boolean> => true;
openModal({
title: TEXT.title,
closeLabel: TEXT.close,
dialogClass: "ui-help",
wide: true,
beforeClose: () => flush(),
mount: (body) => {
const article = el("article", { className: "ui-help__text" });
const toc = el("nav", { className: "ui-help__toc" });
const tocPage = new Map<string, HTMLElement>();
const goTo = (anchor: string): void => {
const page = anchor.split("/")[0];
const target = article.querySelector<HTMLElement>(
`[data-help-anchor="${CSS.escape(anchor)}"]`,
);
target?.scrollIntoView({ block: "start" });
tocPage.forEach((box, key) => box.classList.toggle("is-open", key === page));
toc
.querySelectorAll<HTMLElement>("[data-help-goto]")
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpGoto === anchor));
};
const link = (text: string, anchor: string, className: string): HTMLButtonElement => {
const button = el("button", { className, text, attrs: { type: "button" } });
button.dataset.helpGoto = anchor;
button.addEventListener("click", () => goTo(anchor));
return button;
};
for (const group of GROUPS) {
const own = pages.filter((p) => p.page.startsWith(group.prefix));
if (!own.length) continue;
toc.append(el("p", { className: "ui-help__group", text: group.label }));
for (const page of own) {
const subs = page.items
.filter((item) => item.key !== "00_설명서")
.map((item) => link(item.title, anchorOf(page.page, item.key), "ui-help__link--sub"));
const box = el("div", {
className: "ui-help__toc-page",
children: [
link(page.title, page.page, "ui-help__link"),
el("div", { className: "ui-help__subs", children: subs }),
],
});
tocPage.set(page.page, box);
toc.append(box);
}
}
for (const page of pages) {
const section = el("section", { className: "ui-help__page" });
section.dataset.helpAnchor = page.page;
for (const item of page.items) {
const part = el("section", { className: "ui-help__item" });
part.dataset.helpAnchor = anchorOf(page.page, item.key);
part.dataset.page = page.page;
part.dataset.key = item.key;
part.innerHTML = renderHelpItem(page, item, guide.admin);
section.append(part);
}
article.append(section);
}
if (!pages.length) article.append(el("p", { text: TEXT.empty }));
article.addEventListener("click", (ev) => {
const goto = (ev.target as HTMLElement).closest<HTMLElement>("a.ui-help-goto");
if (!goto) return;
ev.preventDefault();
goTo(goto.dataset.helpGoto ?? "");
});
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
// 모달이 붙은 뒤 이동 — mount 는 붙기 전
requestAnimationFrame(() => goTo(start));
},
});
}
export function createHelpButton(): HTMLButtonElement {
const button = createButton({
label: TEXT.button,
variant: "ghost",
onClick: () => void openHelp(),
});
button.classList.add("ui-help-btn");
button.title = button.ariaLabel = TEXT.tip;
return button;
}
function createStepNavButton(step: -1 | 1): HTMLButtonElement {
const button = createButton({
label: step < 0 ? STEP_TEXT.prev : STEP_TEXT.next,
variant: "ghost",
onClick: () =>
button.dispatchEvent(new CustomEvent("ui-step-nav", { bubbles: true, detail: step })),
});
button.classList.add("ui-step-nav-btn");
button.dataset.stepNav = String(step);
button.title = button.ariaLabel = step < 0 ? STEP_TEXT.prevTip : STEP_TEXT.nextTip;
return button;
}
/** 지금 주소의 묶음 · [<] [>] 차례 — 목록 밖이면 차례 -1 */
function stepNavIndex(): { index: number; last: number } {
const route = location.hash.replace(/^#\/?/, "").split("?")[0];
const group = STEP_NAV_GROUPS.find((routes) => routes.includes(route)) ?? [];
return { index: group.indexOf(route), last: group.length - 1 };
}
/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */
export function placeHelpButton(actions: HTMLElement): void {
if (actions.querySelector(".ui-help-btn")) return;
const row = actions.querySelector<HTMLElement>("[data-help-row]") ?? actions;
if (stepNavIndex().index < 0) return void row.prepend(createHelpButton());
const prev = createStepNavButton(-1);
const next = createStepNavButton(1);
// 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤
const sync = (): void => {
const { index, last } = stepNavIndex();
prev.disabled = index <= 0;
next.disabled = index === last;
};
const onHash = (): void => {
if (prev.isConnected) sync();
else window.removeEventListener("hashchange", onHash); // 화면에서 빠짐 — 풂
};
sync();
window.addEventListener("hashchange", onHash);
row.prepend(createHelpButton(), prev, next);
}