// views/post.js — 게시물 상세 화면 // 라우트: #/post/:postNo // 구성: 카테고리 → 제목 → 요약 → 날짜 → 마크다운 본문 // // 표지 이미지와 하단 갤러리는 두지 않는다. // 이미지는 본문 안에서 마크다운으로 직접 배치한다. ( ![캡션|half](주소) ) import { api } from '../api.js'; import { escapeHtml, formatDate, setBusy, toast } from '../ui.js'; import { renderMarkdown } from '../markdown.js'; /** accent 는 CSS 값으로 들어가므로 헥사 색상만 통과시킨다. */ function safeAccent(value) { const text = typeof value === 'string' ? value.trim() : ''; return /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text) ? text : ''; } /** 정수 안전 변환 */ function toInt(value) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : 0; } /** '12' 처럼 양의 정수인지 확인한다. */ function parsePostNo(value) { const text = String(value === null || value === undefined ? '' : value).trim(); if (!/^\d{1,9}$/.test(text)) return 0; const n = Number(text); return n > 0 ? n : 0; } /** 찾을 수 없음 / 오류 화면 */ function stateHtml(icon, title, detail) { return ( '
' + '' + '

' + escapeHtml(title) + '

' + (detail ? '

' + escapeHtml(detail) + '

' : '') + '작업 목록으로' + '
' ); } /** * 게시물 본문 마크업. * 사용자 데이터는 모두 escapeHtml 을 거치고, 본문만 renderMarkdown 결과를 넣는다. */ function postHtml(post) { const title = String((post && post.title) || '(제목 없음)'); const catName = String((post && post.categoryName) || '미분류'); const catSlug = String((post && post.categorySlug) || ''); const accent = safeAccent(post && post.categoryAccent); const summary = post && post.summary ? String(post.summary) : ''; const created = formatDate(post && post.createdAt); const updated = formatDate(post && post.updatedAt); const catHref = catSlug ? '#/?cat=' + encodeURIComponent(catSlug) : '#/'; let html = '
' + '
' + '' + '' + escapeHtml(catName) + '' + '

' + escapeHtml(title) + '

' + (summary ? '

' + escapeHtml(summary) + '

' : '') + '' + '
'; const body = post && post.contentMd ? renderMarkdown(post.contentMd) : ''; html += '
' + (body || '

본문이 아직 없습니다.

') + '
'; html += '' + '
'; return html; } /** * 게시물 상세 뷰. * @param {Object} params 라우터 캡처값 (postNo) * @param {HTMLElement} outlet * @returns {Function} cleanup */ export async function view(params, outlet) { const postNo = parsePostNo(params && params.postNo); let disposed = false; if (!postNo) { outlet.innerHTML = stateHtml('✦', '게시물을 찾을 수 없습니다.', '주소가 올바르지 않습니다.'); return () => { disposed = true; }; } outlet.innerHTML = '
' + '

불러오는 중…

'; setBusy(true); try { const post = await api.posts.get(postNo); if (disposed) return () => { disposed = true; }; outlet.innerHTML = postHtml(post); document.title = '김낙준 · ' + String((post && post.title) || '작업'); } catch (err) { if (!disposed) { const status = err && err.status; if (status === 404) { outlet.innerHTML = stateHtml('✦', '게시물을 찾을 수 없습니다.', '삭제되었거나 주소가 바뀌었을 수 있습니다.'); } else { outlet.innerHTML = stateHtml('⚠', '게시물을 불러오지 못했습니다.', (err && err.message) || '알 수 없는 오류가 발생했습니다.'); toast((err && err.message) || '게시물을 불러오지 못했습니다.', 'error'); } } } finally { setBusy(false); } return () => { disposed = true; setBusy(false); }; } export default view;