feat(가이드): 538-2 [?] = 넓은 모달 한 가이드 · 차례 묶음 → 페이지 → 기능 · 그림 · 주의/참고 · B01 [?]
- [?] = openModal wide · 왼쪽 차례(임도 설계 B → 안정성평가 E → 예정노선 D · 지금 페이지 펼침) ·
오른쪽 = 모든 페이지 이어 그림 · 처음 = 보이는 data-help-topic 절 → 지금 페이지 절 → 맨 앞 · B05↔B06 짝 걷음
- 그리기: 덩어리마다 원문 줄(data-src) · `` → 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>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { createHelpButton } from "@ui/ui_template_help";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createStepBar } from "@ui/ui_template_workflow_layout";
|
||||
import { setDrawerSteps } from "../A00_Common/app_shell";
|
||||
@@ -358,7 +359,10 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
roleLabel(state.user.role),
|
||||
state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral",
|
||||
);
|
||||
tag.classList.add("b01-dashboard__role");
|
||||
layout.root.querySelector(".ui-general-layout__header")?.append(tag);
|
||||
// [?] 사용자 가이드(538-2) = 배지 왼쪽 — 같은 자리 묶음
|
||||
const head = document.createElement("span");
|
||||
head.className = "b01-dashboard__role ui-help-head";
|
||||
head.append(createHelpButton(), tag);
|
||||
layout.root.querySelector(".ui-general-layout__header")?.append(head);
|
||||
return layout.root;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름).
|
||||
`_` 로 시작하는 파일 · 그림은 항목 아님.
|
||||
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
|
||||
그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +15,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
@@ -21,16 +23,35 @@ router = APIRouter(prefix="/api/help", tags=["help"])
|
||||
|
||||
HELP_ROOT = Path(__file__).resolve().parent.parent / "A05_EduDetail" / "guide"
|
||||
PAGE_RE = re.compile(r"^[BDE]\d{2}$")
|
||||
IMG_RE = re.compile(r"^[A-Za-z0-9_-]+\.(webp|png|jpg)$")
|
||||
|
||||
|
||||
def file_version(blob: bytes) -> str:
|
||||
return hashlib.sha256(blob).hexdigest()[:16]
|
||||
|
||||
|
||||
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
|
||||
def image_version(path: Path) -> str:
|
||||
return format(path.stat().st_mtime_ns, "x")
|
||||
|
||||
|
||||
def page_folder(page: str, root: Path = HELP_ROOT) -> Path:
|
||||
if not PAGE_RE.match(page):
|
||||
raise HTTPException(status_code=404, detail="없는 페이지")
|
||||
folder = root / page
|
||||
return root / page
|
||||
|
||||
|
||||
def image_path(page: str, name: str, root: Path = HELP_ROOT) -> Path:
|
||||
if not IMG_RE.match(name):
|
||||
raise HTTPException(status_code=404, detail="없는 그림")
|
||||
return page_folder(page, root) / name
|
||||
|
||||
|
||||
def read_images(folder: Path) -> dict[str, str]:
|
||||
return {p.name: image_version(p) for p in sorted(folder.iterdir()) if IMG_RE.match(p.name)}
|
||||
|
||||
|
||||
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
|
||||
folder = page_folder(page, root)
|
||||
items = []
|
||||
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
||||
if path.name.startswith("_"):
|
||||
@@ -62,6 +83,7 @@ def read_guide(root: Path = HELP_ROOT) -> list[dict[str, Any]]:
|
||||
"page": folder.name,
|
||||
"title": manual["title"] if manual else folder.name,
|
||||
"items": items,
|
||||
"images": read_images(folder),
|
||||
}
|
||||
)
|
||||
return pages
|
||||
@@ -69,9 +91,17 @@ def read_guide(root: Path = HELP_ROOT) -> list[dict[str, Any]]:
|
||||
|
||||
@router.get("")
|
||||
async def get_guide(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]:
|
||||
return {"pages": read_guide(), "admin": session.get("role") == "SYSTEM_ADMIN"}
|
||||
return {"pages": read_guide(HELP_ROOT), "admin": session.get("role") == "SYSTEM_ADMIN"}
|
||||
|
||||
|
||||
@router.get("/{page}")
|
||||
async def get_help(page: str) -> dict[str, list[dict[str, str]]]:
|
||||
return {"items": read_help(page)}
|
||||
MEDIA = {b"RIFF": "image/webp", b"\x89PNG": "image/png"}
|
||||
|
||||
|
||||
@router.get("/{page}/img/{name}")
|
||||
def get_image(page: str, name: str) -> FileResponse:
|
||||
path = image_path(page, name, HELP_ROOT)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="없는 그림")
|
||||
with path.open("rb") as f: # 올린 그림은 이름과 달리 WebP — 머리 글자로 가름
|
||||
media = MEDIA.get(f.read(4), "image/jpeg")
|
||||
return FileResponse(path, media_type=media, headers={"Cache-Control": "no-cache"})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* PLAN 538 단위검증 헬퍼 — 진짜 `ui_template_help.ts` 의 `renderHelpMarkdown` 을 Node 로 돌려 변환 글을 JSON 으로 냄.
|
||||
* 화면 모듈(단추 · 모달 · 고침)은 빈 가짜로 끼움.
|
||||
*/
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = {
|
||||
"@config/config_frontend": {
|
||||
API_BASE_URL: "/api",
|
||||
ROUTES: {},
|
||||
PLAN_STEP_ROUTES: [],
|
||||
STAB_STEP_ROUTES: [],
|
||||
},
|
||||
"./ui_template_elements": {},
|
||||
"./ui_template_modal": {},
|
||||
"./ui_template_help_edit": {},
|
||||
};
|
||||
const { load } = makeLoader({ root: ROOT, stubs: STUBS });
|
||||
const { renderHelpMarkdown } = load("ui_template", "ui_template_help.ts");
|
||||
|
||||
const ctx = (admin) => ({ page: "B05", images: { "b05_full.webp": "abc" }, admin });
|
||||
const DOC = [
|
||||
"# 종단 설계", // 0
|
||||
"", // 1
|
||||
"첫 문단 [표준 횡단](B06/00_설명서) · [돌쌓기](B05/구조물_돌쌓기(메))", // 2
|
||||
"둘째 줄", // 3
|
||||
"", // 4
|
||||
"- 하나", // 5
|
||||
"- 둘", // 6
|
||||
"", // 7
|
||||
"| 칸 | 뜻 |", // 8
|
||||
"| --- | --- |", // 9
|
||||
"| a | b |", // 10
|
||||
"", // 11
|
||||
"> 주의: 저장 전 확인", // 12
|
||||
"> 둘째 줄", // 13
|
||||
"", // 14
|
||||
"> 참고: 알아 둘 것", // 15
|
||||
"", // 16
|
||||
"", // 17
|
||||
"", // 18
|
||||
"", // 19
|
||||
'<script>alert("x")</script>', // 20
|
||||
].join("\n");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
plain: renderHelpMarkdown(DOC),
|
||||
admin: renderHelpMarkdown(DOC, ctx(true)),
|
||||
user: renderHelpMarkdown(DOC, ctx(false)),
|
||||
crlf: renderHelpMarkdown("# 가\r\n\r\n- 나\r\n- 다\r\n", ctx(false)),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
"""PLAN 538 [?] 사용자 가이드 — 변환 글 · 그림 내려주기 · 바로 고침.
|
||||
|
||||
짝: `ui_template/ui_template_help.ts`(`renderHelpMarkdown`)
|
||||
— `helper_538_help_render.cjs` 가 Node 로 돌림 ·
|
||||
`common_util/common_util_help_router.py`(GET /api/help · 그림 · PUT · POST).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util import common_util_help_router as help_router
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def html() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_538_help_render.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_덩어리마다_원문_줄(html) -> None:
|
||||
admin = html["admin"]
|
||||
for tag in [
|
||||
'<h2 data-src="0-0">',
|
||||
'<p data-src="2-3">',
|
||||
'<ul data-src="5-6">',
|
||||
'class="ui-markdown-table" data-src="8-10"',
|
||||
'data-src="12-13"',
|
||||
'<figure data-src="17-17"',
|
||||
]:
|
||||
assert tag in admin, tag
|
||||
assert '<ul data-src="2-3">' in html["crlf"]
|
||||
|
||||
|
||||
def test_페이지_안_넘기면_옛_출력(html) -> None:
|
||||
"""B01 지식 채팅 — 원문 줄 · 그림 · 상자 · 링크 없음."""
|
||||
plain = html["plain"]
|
||||
assert "data-src" not in plain and "<figure" not in plain and "<aside" not in plain
|
||||
assert plain.startswith("<h2>종단 설계</h2><p>첫 문단 [표준 횡단](B06/00_설명서)")
|
||||
|
||||
|
||||
def test_그림_상자_링크(html) -> None:
|
||||
admin, user = html["admin"], html["user"]
|
||||
assert (
|
||||
'<img src="/api/help/B05/img/b05_full.webp?v=abc" alt="화면 통째" loading="lazy">' in admin
|
||||
)
|
||||
assert "<figcaption>화면 통째</figcaption>" in admin
|
||||
assert "그림 없음 — 더블클릭해 지정" in admin and "그림 없음" not in user
|
||||
assert "그림 준비 중" in user
|
||||
assert (
|
||||
'<aside class="ui-help-note ui-help-note--warn" data-src="12-13"><strong>주의</strong>'
|
||||
in admin
|
||||
)
|
||||
assert '<aside class="ui-help-note ui-help-note--info"' in admin
|
||||
assert 'data-help-goto="B06/00_설명서">표준 횡단</a>' in admin
|
||||
assert 'data-help-goto="B05/구조물_돌쌓기(메)">돌쌓기</a>' in admin
|
||||
|
||||
|
||||
def test_html_은_먼저_막음(html) -> None:
|
||||
for out in html.values():
|
||||
assert "<script>" not in out
|
||||
assert "../x.webp" in html["admin"] and 'data-img="../x.webp"' not in html["admin"]
|
||||
|
||||
|
||||
# ── 서버 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(monkeypatch):
|
||||
path = Path(tempfile.mkdtemp())
|
||||
(path / "B05").mkdir()
|
||||
(path / "B05" / "00_설명서.md").write_text("# 종단\n\n첫 문단\n", encoding="utf-8")
|
||||
monkeypatch.setattr(help_router, "HELP_ROOT", path)
|
||||
yield path
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
def client(role: str = "SYSTEM_ADMIN") -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(help_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": role, "user_id": 1}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_guide_admin_칸(root) -> None:
|
||||
assert client().get("/api/help").json()["admin"] is True
|
||||
body = client("USER").get("/api/help").json()
|
||||
assert body["admin"] is False and body["pages"][0]["page"] == "B05"
|
||||
|
||||
|
||||
def test_그림_내려주기_no_cache(root) -> None:
|
||||
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
|
||||
res = client("USER").get("/api/help/B05/img/full.webp")
|
||||
assert res.status_code == 200 and res.content == b"RIFF0000WEBP"
|
||||
assert res.headers["cache-control"] == "no-cache"
|
||||
assert res.headers["content-type"] == "image/webp"
|
||||
assert list(client().get("/api/help").json()["pages"][0]["images"]) == ["full.webp"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"/api/help/B05/img/none.webp",
|
||||
"/api/help/B05/img/00_설명서.md",
|
||||
"/api/help/B05/img/..%2F..%2Fmain.py",
|
||||
"/api/help/B05/img/a.gif",
|
||||
"/api/help/..%2FB05/img/full.webp",
|
||||
"/api/help/b05/img/full.webp",
|
||||
],
|
||||
)
|
||||
def test_그림_404(root, url) -> None:
|
||||
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
|
||||
assert client().get(url).status_code == 404
|
||||
@@ -1,4 +1,4 @@
|
||||
/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 · [<] [>](280-2)도 같은 정사각 */
|
||||
/* [?] 사용자 가이드(54-2 · 538) — 단추는 글자 폭만 · 넓은 모달 = 왼쪽 차례 · 오른쪽 가이드 통째 · [<] [>](280-2)도 같은 정사각 */
|
||||
.ui-sidebar-actions .ui-help-btn,
|
||||
.ui-help-btn,
|
||||
.ui-sidebar-actions .ui-step-nav-btn,
|
||||
@@ -10,19 +10,22 @@
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.ui-help.ui-modal {
|
||||
width: min(960px, 92vw);
|
||||
height: var(--modal-max, 80vh);
|
||||
/* B01 대시보드 머리 — [?] + 역할 배지 한 줄 */
|
||||
.ui-help-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-help .ui-modal__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui-help__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: var(--spacing-16);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -37,7 +40,30 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ui-help__link {
|
||||
.ui-help__group {
|
||||
margin: var(--spacing-12) 0 var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-help__group:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ui-help__subs {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
padding-left: var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-help__toc-page.is-open > .ui-help__subs {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ui-help__link,
|
||||
.ui-help__link--sub {
|
||||
width: 100%;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -48,17 +74,91 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-help__link--sub {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.ui-help__toc-page.is-open > .ui-help__link {
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-help__link:hover,
|
||||
.ui-help__link.is-active {
|
||||
.ui-help__link--sub:hover,
|
||||
.ui-help__link.is-active,
|
||||
.ui-help__link--sub.is-active {
|
||||
background: color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ui-help__text {
|
||||
overflow-y: auto;
|
||||
padding-right: var(--spacing-8);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ui-help__page + .ui-help__page {
|
||||
margin-top: var(--spacing-24);
|
||||
padding-top: var(--spacing-24);
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-help__item + .ui-help__item {
|
||||
margin-top: var(--spacing-16);
|
||||
}
|
||||
|
||||
.ui-help__page,
|
||||
.ui-help__item {
|
||||
scroll-margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-help-figure {
|
||||
max-width: 900px;
|
||||
margin: var(--spacing-12) 0;
|
||||
}
|
||||
|
||||
.ui-help-figure img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ui-help-figure figcaption {
|
||||
margin-top: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.ui-help-figure__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 120px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.ui-help-note {
|
||||
display: block;
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.ui-help-note--warn {
|
||||
border-left-color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
}
|
||||
|
||||
.ui-help-goto {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui-help__text h2 {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
font-size: 1.4em;
|
||||
|
||||
+191
-45
@@ -1,9 +1,11 @@
|
||||
/* =============================================================================
|
||||
* ui_template_help.ts
|
||||
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
|
||||
* 글 = `A05_EduDetail/guide/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
|
||||
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
|
||||
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
|
||||
* [?] 사용자 가이드(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`).
|
||||
@@ -14,14 +16,43 @@ import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@confi
|
||||
import { createButton, el } from "./ui_template_elements";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
|
||||
interface HelpItem {
|
||||
export interface HelpItem {
|
||||
key: string;
|
||||
title: string;
|
||||
text: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" };
|
||||
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[]> = [
|
||||
@@ -36,7 +67,7 @@ const STEP_NAV_GROUPS: ReadonlyArray<readonly string[]> = [
|
||||
STAB_STEP_ROUTES,
|
||||
];
|
||||
|
||||
/** 설명서 폴더 이름 = 주소 머리(#/b05-… → B05 · #/d02-… → D02) */
|
||||
/** 가이드 폴더 이름 = 주소 머리(#/b05-… → B05 · #/d02-… → D02) */
|
||||
function currentPage(): string {
|
||||
const head = /^#\/([bde]0\d)/i.exec(location.hash)?.[1];
|
||||
return head ? head.toUpperCase() : "";
|
||||
@@ -49,18 +80,53 @@ function escapeHtml(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function inline(text: string): string {
|
||||
return escapeHtml(text)
|
||||
/** 가이드 안 링크 `[글](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;
|
||||
}
|
||||
|
||||
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */
|
||||
export function renderHelpMarkdown(text: string): string {
|
||||
/** 페이지를 넘긴 때만 — 덩어리 원문 줄(`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()
|
||||
@@ -70,7 +136,8 @@ export function renderHelpMarkdown(text: string): string {
|
||||
const separator = (line: string): boolean =>
|
||||
cells(line).every((cell) => /^:?-{3,}:?$/.test(cell));
|
||||
const flush = (): void => {
|
||||
if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`);
|
||||
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;
|
||||
@@ -80,30 +147,49 @@ export function renderHelpMarkdown(text: string): string {
|
||||
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"><table><thead><tr>${headers.map((cell) => `<th>${inline(cell)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, cell) => `<td>${inline(row[cell] ?? "")}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`,
|
||||
`<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 (head) {
|
||||
else if (ctx && line.startsWith(">")) {
|
||||
flush();
|
||||
out.push(`<h${head[1].length + 1}>${inline(head[2])}</h${head[1].length + 1}>`);
|
||||
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) out.push(`<${kind}>`);
|
||||
if (!list) {
|
||||
listAt = out.length;
|
||||
listFrom = index;
|
||||
out.push("");
|
||||
}
|
||||
list = kind;
|
||||
out.push(`<li>${inline(item[2])}</li>`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -118,51 +204,111 @@ function visibleTopics(): string[] {
|
||||
.map((node) => node.dataset.helpTopic ?? "");
|
||||
}
|
||||
|
||||
async function fetchItems(page: string): Promise<HelpItem[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/help/${page}`, {
|
||||
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 { items: HelpItem[] }).items : [];
|
||||
return response.ok ? ((await response.json()) as HelpGuide) : { pages: [], admin: false };
|
||||
}
|
||||
|
||||
/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */
|
||||
const PAIR: Record<string, string> = { B05: "B06", B06: "B05" };
|
||||
/** 한 기능 절 — 자리 이름 `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 page = currentPage();
|
||||
const pair = PAIR[page];
|
||||
const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]);
|
||||
const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))];
|
||||
const topics = visibleTopics();
|
||||
const first = [...topics].reverse().find((key) => items.some((item) => item.key === key));
|
||||
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} · ${page}`,
|
||||
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 show = (key: string): void => {
|
||||
const item = items.find((entry) => entry.key === key);
|
||||
article.innerHTML = item ? renderHelpMarkdown(item.text) : `<p>${TEXT.empty}</p>`;
|
||||
article.dataset.helpKey = key;
|
||||
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("button")
|
||||
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key));
|
||||
.querySelectorAll<HTMLElement>("[data-help-goto]")
|
||||
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpGoto === anchor));
|
||||
};
|
||||
for (const item of items) {
|
||||
const link = el("button", {
|
||||
className: "ui-help__link",
|
||||
text: item.title,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
link.dataset.helpKey = item.key;
|
||||
link.addEventListener("click", () => show(item.key));
|
||||
toc.append(link);
|
||||
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] }));
|
||||
show(first ?? items[0]?.key ?? "");
|
||||
// 모달이 붙은 뒤 이동 — mount 는 붙기 전
|
||||
requestAnimationFrame(() => goTo(start));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user