서버 게시글
This commit is contained in:
73
GameServer/wwwroot/js/views/about.js
Normal file
73
GameServer/wwwroot/js/views/about.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// views/about.js — 소개 화면
|
||||
// 라우트: #/about
|
||||
// 본문은 아래 ABOUT_MD 상수 하나만 고치면 된다.
|
||||
|
||||
import { renderMarkdown } from '../markdown.js';
|
||||
|
||||
/* 여기 내용을 자유롭게 수정하세요 */
|
||||
const ABOUT_MD = `
|
||||
## 안녕하세요, 게임 서버를 만드는 개발자입니다.
|
||||
|
||||
Unity 클라이언트가 기대는 **서버**를 설계하고 만듭니다.
|
||||
게임이 재미있으려면 그 아래가 조용히 잘 돌아가야 한다고 생각합니다.
|
||||
지금은 개인 프로젝트 **Genesis**의 서버를 혼자 만들면서,
|
||||
캐릭터 데이터 구조부터 API 설계, 배포까지 전부 직접 다루고 있습니다.
|
||||
|
||||
---
|
||||
|
||||
### 지금 하고 있는 것
|
||||
|
||||
- **Genesis 게임 서버** — Unity 게임용 REST API 서버.
|
||||
캐릭터 생성과 성장, 유저별 보유 캐릭터 관리를 담당합니다.
|
||||
- **이 포트폴리오 사이트** — 같은 서버 위에서 함께 돌아갑니다.
|
||||
게임 API와 한 프로세스를 쓰면서 정적 SPA로 화면을 그립니다.
|
||||
|
||||
### 쓰는 기술
|
||||
|
||||
- **서버** — C# / .NET 9, ASP.NET Core, Entity Framework Core
|
||||
- **데이터베이스** — MariaDB (Pomelo EF Core 프로바이더)
|
||||
- **클라이언트** — Unity, C#
|
||||
- **프론트엔드** — 프레임워크 없는 순수 ES 모듈, CSS 디자인 시스템
|
||||
|
||||
### 일하는 방식
|
||||
|
||||
> 돌아가게 만든 다음, 왜 돌아가는지 설명할 수 있을 때까지 다듬습니다.
|
||||
|
||||
빌드 한 번으로 끝나는 배포를 좋아하고,
|
||||
남이 읽을 코드라고 생각하고 씁니다.
|
||||
문제가 생겼을 때 로그만 보고 원인을 짚을 수 있는 구조를 지향합니다.
|
||||
|
||||
---
|
||||
|
||||
### 연락
|
||||
|
||||
작업 문의나 협업 제안은 언제든 환영합니다.
|
||||
왼쪽 위 **작업** 탭에서 만든 것들을 먼저 봐 주세요.
|
||||
`;
|
||||
|
||||
/**
|
||||
* 소개 뷰.
|
||||
* @param {Object} params 사용하지 않음
|
||||
* @param {HTMLElement} outlet
|
||||
* @returns {Function} cleanup
|
||||
*/
|
||||
export async function view(params, outlet) {
|
||||
outlet.innerHTML =
|
||||
'<section class="section">' +
|
||||
'<div class="section__head">' +
|
||||
'<h2 class="section__title">소개</h2>' +
|
||||
'<p class="section__sub">무엇을 만들고, 어떻게 일하는지.</p>' +
|
||||
'</div>' +
|
||||
'<div class="glass post">' +
|
||||
'<div class="post__body">' + renderMarkdown(ABOUT_MD) + '</div>' +
|
||||
'</div>' +
|
||||
'</section>';
|
||||
|
||||
document.title = '김낙준 · 소개';
|
||||
|
||||
return () => {
|
||||
// 붙인 리스너가 없어 따로 정리할 것이 없다.
|
||||
};
|
||||
}
|
||||
|
||||
export default view;
|
||||
1002
GameServer/wwwroot/js/views/admin.js
Normal file
1002
GameServer/wwwroot/js/views/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
431
GameServer/wwwroot/js/views/home.js
Normal file
431
GameServer/wwwroot/js/views/home.js
Normal file
@@ -0,0 +1,431 @@
|
||||
// views/home.js — 홈(작업 인덱스) 화면
|
||||
// 라우트: #/ · #/?cat=<slug>
|
||||
// 구성: 히어로 → 인라인 카테고리 필터 → 대표 작업 한 건 → 번호 매긴 인덱스
|
||||
//
|
||||
// 카드 그리드 대신 '작품집 목차' 형태를 쓰되, 이미지가 늘 자리를 갖도록
|
||||
// 맨 앞 글은 크게(lead) 보여 주고 나머지 행에는 작은 썸네일을 붙인다.
|
||||
// 이미지가 없는 글은 제목 첫 글자를 넣은 파스텔 판으로 대신한다.
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { el, escapeHtml, formatDate, setBusy, skeletonRows, toast } from '../ui.js';
|
||||
|
||||
/** 한 번에 불러오는 게시물 수 */
|
||||
const PAGE_SIZE = 20;
|
||||
/** 카테고리 캐시 수명 (ms) */
|
||||
const CATEGORY_TTL_MS = 60000;
|
||||
|
||||
// 카테고리 목록은 모듈 스코프에 캐시한다.
|
||||
// 필터만 바뀌는 경우(#/ ↔ #/?cat=game) 뷰가 다시 마운트돼도 다시 요청하지 않는다.
|
||||
let catCache = null;
|
||||
let catCachedAt = 0;
|
||||
let catInflight = null;
|
||||
|
||||
/**
|
||||
* 카테고리 목록을 가져온다. 캐시가 살아 있으면 즉시 반환한다.
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
function getCategories() {
|
||||
if (catCache && Date.now() - catCachedAt < CATEGORY_TTL_MS) {
|
||||
return Promise.resolve(catCache);
|
||||
}
|
||||
if (catInflight) return catInflight;
|
||||
|
||||
catInflight = api.categories.list().then(
|
||||
(list) => {
|
||||
catCache = Array.isArray(list) ? list : [];
|
||||
catCachedAt = Date.now();
|
||||
catInflight = null;
|
||||
return catCache;
|
||||
},
|
||||
(err) => {
|
||||
catInflight = null;
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
return catInflight;
|
||||
}
|
||||
|
||||
/** params.cat 정규화. 비었거나 형식이 이상하면 'all' */
|
||||
function normalizeSlug(value) {
|
||||
const text = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
if (!text || text === 'all') return 'all';
|
||||
return /^[a-z0-9-]{1,64}$/.test(text) ? text : 'all';
|
||||
}
|
||||
|
||||
/** 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 : '';
|
||||
}
|
||||
|
||||
/** http(s) 또는 사이트 상대경로만 허용. 그 외에는 빈 문자열 */
|
||||
function safeUrl(value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!text) return '';
|
||||
if (text.slice(0, 2) === '//') return '';
|
||||
if (text.charAt(0) === '/') return text;
|
||||
return /^https?:\/\//i.test(text) ? text : '';
|
||||
}
|
||||
|
||||
/** 정수 안전 변환 */
|
||||
function toInt(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
||||
}
|
||||
|
||||
/** 01, 02 … 형태의 두 자리 번호 */
|
||||
function padNo(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 'YYYY. MM. DD.' → 'YY.MM' (인덱스에서는 짧게 쓴다) */
|
||||
function shortDate(iso) {
|
||||
const full = formatDate(iso);
|
||||
const m = /^(\d{4})\.\s*(\d{2})\./.exec(full);
|
||||
return m ? m[1].slice(2) + '.' + m[2] : full;
|
||||
}
|
||||
|
||||
/** 썸네일이 없을 때 쓸 첫 글자 (서로게이트 페어 안전) */
|
||||
function firstChar(title) {
|
||||
const text = String(title || '').trim();
|
||||
if (!text) return '·';
|
||||
return Array.from(text)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판.
|
||||
* @param {string} cls 바깥 figure 의 클래스
|
||||
* @param {string} url 안전하게 걸러진 이미지 주소 ('' 가능)
|
||||
* @param {string} title alt / 대체 글자용
|
||||
*/
|
||||
function mediaHtml(cls, url, title) {
|
||||
if (url) {
|
||||
return (
|
||||
'<figure class="' + cls + '">' +
|
||||
'<img src="' + escapeHtml(url) + '" alt="' + escapeHtml(title) + '"' +
|
||||
' loading="lazy" decoding="async">' +
|
||||
'</figure>'
|
||||
);
|
||||
}
|
||||
return (
|
||||
'<figure class="' + cls + ' ' + cls + '--none" aria-hidden="true">' +
|
||||
'<span>' + escapeHtml(firstChar(title)) + '</span>' +
|
||||
'</figure>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 필터 한 칸. 칩이 아니라 글자 링크다.
|
||||
*/
|
||||
function filterHtml(slug, name, count, accent, active) {
|
||||
const href = slug === 'all' ? '#/' : '#/?cat=' + encodeURIComponent(slug);
|
||||
const style = accent ? ' style="--card-accent:' + accent + '"' : '';
|
||||
return (
|
||||
'<a class="filter' + (active ? ' is-active' : '') + '" href="' + escapeHtml(href) + '"' +
|
||||
style + (active ? ' aria-current="true"' : '') + '>' +
|
||||
escapeHtml(name) +
|
||||
'<span class="filter__count">' + toInt(count) + '</span>' +
|
||||
'</a>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 작업(가장 최근 글) 한 건을 크게 보여 준다.
|
||||
* @param {Object} post PostSummaryDto
|
||||
*/
|
||||
function leadHtml(post) {
|
||||
const postNo = toInt(post && post.postNo);
|
||||
const title = String((post && post.title) || '(제목 없음)');
|
||||
const catName = String((post && post.categoryName) || '미분류');
|
||||
const summary = post && post.summary ? String(post.summary) : '';
|
||||
const accent = safeAccent(post && post.categoryAccent);
|
||||
const thumb = safeUrl(post && post.thumbnailUrl);
|
||||
|
||||
return (
|
||||
'<a class="lead" href="#/post/' + postNo + '"' +
|
||||
(accent ? ' style="--card-accent:' + accent + '"' : '') + '>' +
|
||||
mediaHtml('lead__media', thumb, title) +
|
||||
'<div class="lead__body">' +
|
||||
'<span class="lead__label">최신 작업</span>' +
|
||||
'<span class="lead__cat">' + escapeHtml(catName) + '</span>' +
|
||||
'<h3 class="lead__title">' + escapeHtml(title) + '</h3>' +
|
||||
(summary ? '<p class="lead__sum">' + escapeHtml(summary) + '</p>' : '') +
|
||||
'<span class="lead__meta">' +
|
||||
escapeHtml(formatDate(post && post.createdAt)) +
|
||||
'</span>' +
|
||||
'<span class="lead__more">자세히 보기</span>' +
|
||||
'</div>' +
|
||||
'</a>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 행 하나.
|
||||
* @param {Object} post PostSummaryDto
|
||||
* @param {number} order 화면상의 순번 (1 부터)
|
||||
*/
|
||||
function rowHtml(post, order) {
|
||||
const postNo = toInt(post && post.postNo);
|
||||
const title = String((post && post.title) || '(제목 없음)');
|
||||
const catName = String((post && post.categoryName) || '미분류');
|
||||
const summary = post && post.summary ? String(post.summary) : '';
|
||||
const accent = safeAccent(post && post.categoryAccent);
|
||||
const thumb = safeUrl(post && post.thumbnailUrl);
|
||||
|
||||
return (
|
||||
'<li class="index__item">' +
|
||||
'<a class="idx" href="#/post/' + postNo + '"' +
|
||||
(accent ? ' style="--card-accent:' + accent + '"' : '') + '>' +
|
||||
'<span class="idx__no">' + padNo(order) + '</span>' +
|
||||
mediaHtml('idx__thumb', thumb, title) +
|
||||
'<span class="idx__body">' +
|
||||
'<span class="idx__title">' + escapeHtml(title) + '</span>' +
|
||||
(summary ? '<span class="idx__sum">' + escapeHtml(summary) + '</span>' : '') +
|
||||
'</span>' +
|
||||
'<span class="idx__meta">' +
|
||||
'<span class="idx__cat">' + escapeHtml(catName) + '</span>' +
|
||||
'<span class="idx__date">' + escapeHtml(shortDate(post && post.createdAt)) + '</span>' +
|
||||
'</span>' +
|
||||
'</a>' +
|
||||
'</li>'
|
||||
);
|
||||
}
|
||||
|
||||
/** 화면 뼈대 (API 데이터가 섞이지 않는 정적 마크업) */
|
||||
const SHELL_HTML =
|
||||
'<section class="hero">' +
|
||||
'<h1 class="hero__title">게임을 <span class="hero__grad">만듭니다</span></h1>' +
|
||||
'<div class="hero__meta" id="home-stats"></div>' +
|
||||
'</section>' +
|
||||
'<section class="section">' +
|
||||
'<div class="section__head">' +
|
||||
'<h2 class="section__title">작업</h2>' +
|
||||
'<nav class="filters" id="home-cats" aria-label="카테고리 필터" aria-busy="true"></nav>' +
|
||||
'</div>' +
|
||||
'<div id="home-lead"></div>' +
|
||||
'<ol class="index" id="home-grid"></ol>' +
|
||||
'<div id="home-state"></div>' +
|
||||
'<div id="home-more"></div>' +
|
||||
'</section>';
|
||||
|
||||
/**
|
||||
* 홈 뷰.
|
||||
* @param {Object} params 라우터가 넘겨주는 캡처/쿼리 값 (cat)
|
||||
* @param {HTMLElement} outlet
|
||||
* @returns {Function} cleanup
|
||||
*/
|
||||
export async function view(params, outlet) {
|
||||
const query = params || {};
|
||||
const activeSlug = normalizeSlug(query.cat);
|
||||
|
||||
let disposed = false;
|
||||
let page = 1;
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
|
||||
outlet.insertAdjacentHTML('beforeend', SHELL_HTML);
|
||||
|
||||
const statsEl = outlet.querySelector('#home-stats');
|
||||
const catsEl = outlet.querySelector('#home-cats');
|
||||
const leadEl = outlet.querySelector('#home-lead');
|
||||
const listEl = outlet.querySelector('#home-grid');
|
||||
const stateEl = outlet.querySelector('#home-state');
|
||||
const moreEl = outlet.querySelector('#home-more');
|
||||
|
||||
// ── 카테고리 필터 ─────────────────────────────────────────────
|
||||
function renderFilters(cats) {
|
||||
const list = Array.isArray(cats) ? cats : [];
|
||||
const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0);
|
||||
|
||||
let html = filterHtml('all', '전체', totalPosts, '', activeSlug === 'all');
|
||||
for (const cat of list) {
|
||||
const slug = String((cat && cat.slug) || '');
|
||||
if (!slug) continue;
|
||||
html += filterHtml(
|
||||
slug,
|
||||
String((cat && cat.name) || slug),
|
||||
toInt(cat && cat.postCount),
|
||||
safeAccent(cat && cat.accent),
|
||||
slug.toLowerCase() === activeSlug
|
||||
);
|
||||
}
|
||||
catsEl.innerHTML = html;
|
||||
catsEl.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
|
||||
function renderStats(cats) {
|
||||
const list = Array.isArray(cats) ? cats : [];
|
||||
const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0);
|
||||
statsEl.innerHTML =
|
||||
'<span class="hero__stat">작업 <b>' + totalPosts + '</b></span>' +
|
||||
'<span class="hero__stat">카테고리 <b>' + list.length + '</b></span>' +
|
||||
'<span class="hero__stat">Unity · .NET · MariaDB</span>';
|
||||
}
|
||||
|
||||
// 필터를 누르면 해시에 기록한다.
|
||||
// 보조 버튼·수정키 클릭은 브라우저 기본 동작(새 탭 등)에 맡긴다.
|
||||
function onCatsClick(ev) {
|
||||
if (ev.defaultPrevented) return;
|
||||
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||||
const target = ev.target instanceof Element ? ev.target.closest('a.filter') : null;
|
||||
if (!target || !catsEl.contains(target)) return;
|
||||
ev.preventDefault();
|
||||
const next = target.getAttribute('href');
|
||||
if (next && next !== location.hash) location.hash = next;
|
||||
}
|
||||
|
||||
// ── 상태 표시 ─────────────────────────────────────────────────
|
||||
function clearState() {
|
||||
stateEl.innerHTML = '';
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
leadEl.innerHTML = '';
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.add('is-hidden');
|
||||
moreEl.innerHTML = '';
|
||||
const filtered = activeSlug !== 'all';
|
||||
stateEl.innerHTML =
|
||||
'<div class="empty">' +
|
||||
'<p>' + (filtered ? '이 카테고리에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '</p>' +
|
||||
'<p class="tagline">' + (filtered ? '다른 카테고리도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '</p>' +
|
||||
(filtered ? '<a class="btn btn--ghost" href="#/">전체 보기</a>'
|
||||
: '<a class="btn btn--ghost" href="#/admin">관리자</a>') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function showError(err) {
|
||||
leadEl.innerHTML = '';
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.add('is-hidden');
|
||||
moreEl.innerHTML = '';
|
||||
stateEl.innerHTML =
|
||||
'<div class="empty">' +
|
||||
'<p>작업 목록을 불러오지 못했습니다.</p>' +
|
||||
'<p class="tagline">' + escapeHtml((err && err.message) || '알 수 없는 오류가 발생했습니다.') + '</p>' +
|
||||
'</div>';
|
||||
const box = stateEl.querySelector('.empty');
|
||||
if (box) {
|
||||
box.appendChild(
|
||||
el('button', {
|
||||
class: 'btn btn--ghost',
|
||||
type: 'button',
|
||||
text: '다시 시도',
|
||||
onclick: () => { loadFirstPage(); }
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 목록 ──────────────────────────────────────────────────────
|
||||
/** 첫 페이지: 맨 앞 글은 lead 로, 나머지는 인덱스 행으로. */
|
||||
function drawFirst(items) {
|
||||
leadEl.innerHTML = leadHtml(items[0]);
|
||||
const rest = items.slice(1);
|
||||
listEl.innerHTML = rest.map((p, i) => rowHtml(p, i + 2)).join('');
|
||||
listEl.classList.toggle('is-hidden', rest.length === 0);
|
||||
}
|
||||
|
||||
function drawMore(items) {
|
||||
const startAt = loaded - items.length;
|
||||
listEl.classList.remove('is-hidden');
|
||||
listEl.insertAdjacentHTML('beforeend', items.map((p, i) => rowHtml(p, startAt + i + 1)).join(''));
|
||||
}
|
||||
|
||||
function renderMoreButton() {
|
||||
moreEl.innerHTML = '';
|
||||
if (loaded >= total) return;
|
||||
const btn = el('button', {
|
||||
class: 'btn btn--ghost',
|
||||
type: 'button',
|
||||
text: '더 보기',
|
||||
onclick: () => { loadNextPage(btn); }
|
||||
});
|
||||
moreEl.appendChild(btn);
|
||||
}
|
||||
|
||||
async function loadFirstPage() {
|
||||
page = 1;
|
||||
loaded = 0;
|
||||
total = 0;
|
||||
clearState();
|
||||
moreEl.innerHTML = '';
|
||||
leadEl.innerHTML = '';
|
||||
listEl.classList.remove('is-hidden');
|
||||
listEl.innerHTML = skeletonRows(4);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.posts.list({ category: activeSlug, page: 1, size: PAGE_SIZE });
|
||||
if (disposed) return;
|
||||
const items = res && Array.isArray(res.items) ? res.items : [];
|
||||
loaded = items.length;
|
||||
total = Math.max(toInt(res && res.total), loaded);
|
||||
if (!items.length) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
drawFirst(items);
|
||||
renderMoreButton();
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
showError(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNextPage(btn) {
|
||||
if (btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '불러오는 중…';
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.posts.list({ category: activeSlug, page: page + 1, size: PAGE_SIZE });
|
||||
if (disposed) return;
|
||||
const items = res && Array.isArray(res.items) ? res.items : [];
|
||||
page += 1;
|
||||
loaded += items.length;
|
||||
total = Math.max(toInt(res && res.total), loaded);
|
||||
// 빈 페이지가 오면 더 이상 없는 것으로 본다(무한 클릭 방지).
|
||||
if (!items.length) total = loaded;
|
||||
else drawMore(items);
|
||||
renderMoreButton();
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
// 이미 그려진 행은 그대로 두고 버튼만 되돌린다.
|
||||
btn.disabled = false;
|
||||
btn.textContent = '더 보기';
|
||||
toast((err && err.message) || '더 불러오지 못했습니다.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
catsEl.addEventListener('click', onCatsClick);
|
||||
|
||||
// 카테고리와 첫 페이지는 서로를 기다리지 않는다.
|
||||
getCategories().then(
|
||||
(cats) => {
|
||||
if (disposed) return;
|
||||
renderFilters(cats);
|
||||
renderStats(cats);
|
||||
},
|
||||
() => {
|
||||
if (disposed) return;
|
||||
// 카테고리를 못 받아도 목록 자체는 보여준다.
|
||||
renderFilters([]);
|
||||
statsEl.innerHTML = '<span class="hero__stat">Unity · .NET · MariaDB</span>';
|
||||
}
|
||||
);
|
||||
|
||||
// await 하지 않는다: 라우터가 곧바로 cleanup 을 받아 갈 수 있게 한다.
|
||||
loadFirstPage();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
catsEl.removeEventListener('click', onCatsClick);
|
||||
setBusy(false);
|
||||
};
|
||||
}
|
||||
|
||||
export default view;
|
||||
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