Files
Genesis_GameServer/GameServer/wwwroot/js/views/post.js
2026-09-03 19:18:46 +09:00

136 lines
5.1 KiB
JavaScript

// 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 (
'<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;