136 lines
5.1 KiB
JavaScript
136 lines
5.1 KiB
JavaScript
// views/post.js — 게시물 상세 화면
|
|
// 라우트: #/post/:postNo
|
|
// 구성: 카테고리 → 제목 → 요약 → 날짜 → 마크다운 본문
|
|
//
|
|
// 표지 이미지와 하단 갤러리는 두지 않는다.
|
|
// 이미지는 본문 안에서 마크다운으로 직접 배치한다. (  )
|
|
|
|
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 (
|
|
'<div class="empty">' +
|
|
'<div class="empty__icon" aria-hidden="true">' + icon + '</div>' +
|
|
'<p>' + escapeHtml(title) + '</p>' +
|
|
(detail ? '<p class="tagline">' + escapeHtml(detail) + '</p>' : '') +
|
|
'<a class="btn btn--ghost" href="#/">작업 목록으로</a>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 게시물 본문 마크업.
|
|
* 사용자 데이터는 모두 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 =
|
|
'<article class="post"' + (accent ? ' style="--card-accent:' + accent + '"' : '') + '>' +
|
|
'<header class="post__head">' +
|
|
'<a class="post__cat" href="' + escapeHtml(catHref) + '">' +
|
|
'<span class="cat__dot" aria-hidden="true"' + (accent ? ' style="background:' + accent + '"' : '') + '></span>' +
|
|
escapeHtml(catName) +
|
|
'</a>' +
|
|
'<h1 class="post__title">' + escapeHtml(title) + '</h1>' +
|
|
(summary ? '<p class="hero__sub">' + escapeHtml(summary) + '</p>' : '') +
|
|
'<div class="post__meta">' +
|
|
(created ? '<span>작성 ' + escapeHtml(created) + '</span>' : '') +
|
|
(updated && updated !== created ? '<span>수정 ' + escapeHtml(updated) + '</span>' : '') +
|
|
'</div>' +
|
|
'</header>';
|
|
|
|
const body = post && post.contentMd ? renderMarkdown(post.contentMd) : '';
|
|
html += '<div class="post__body">' + (body || '<p class="md-p">본문이 아직 없습니다.</p>') + '</div>';
|
|
|
|
html +=
|
|
'<nav class="post__nav">' +
|
|
'<a class="btn btn--ghost" href="#/">← 작업 목록</a>' +
|
|
(catSlug ? '<a class="btn btn--ghost" href="' + escapeHtml(catHref) + '">' +
|
|
escapeHtml(catName) + ' 더 보기</a>' : '') +
|
|
'</nav>' +
|
|
'</article>';
|
|
|
|
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 =
|
|
'<div class="empty"><div class="empty__icon" aria-hidden="true">◌</div>' +
|
|
'<p>불러오는 중…</p></div>';
|
|
|
|
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;
|