서버 게시글
This commit is contained in:
135
GameServer/wwwroot/js/views/post.js
Normal file
135
GameServer/wwwroot/js/views/post.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user