323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
// views/home.js — 홈(작업 목록) 화면
|
|
// 라우트: #/ · #/?cat=<slug>
|
|
// 구성: 히어로 → 카테고리별 묶음, 각 묶음 안에서 번호 매긴 목록
|
|
//
|
|
// 날짜순으로 전부 섞어 보여 주지 않는다. 작업의 선후보다 분야가 중요해서
|
|
// 카테고리마다 제목을 달고 그 아래에 작업을 나열한다.
|
|
// 모든 항목은 같은 크기다. 대신 행 간격을 넉넉히 두어 썸네일이 잘 보이게 한다.
|
|
|
|
import { api } from '../api.js';
|
|
import { el, escapeHtml, formatDate, setBusy, skeletonRows } from '../ui.js';
|
|
|
|
/** 한 번에 긁어오는 게시물 수 (여러 번 나눠 받아 전부 모은다) */
|
|
const FETCH_SIZE = 50;
|
|
/** 카테고리 캐시 수명 (ms) */
|
|
const CATEGORY_TTL_MS = 60000;
|
|
|
|
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, 없으면 첫 글자를 넣은 판. */
|
|
function thumbHtml(url, title) {
|
|
if (url) {
|
|
return (
|
|
'<figure class="idx__thumb">' +
|
|
'<img src="' + escapeHtml(url) + '" alt="' + escapeHtml(title) + '"' +
|
|
' loading="lazy" decoding="async">' +
|
|
'</figure>'
|
|
);
|
|
}
|
|
return (
|
|
'<figure class="idx__thumb idx__thumb--none" aria-hidden="true">' +
|
|
'<span>' + escapeHtml(firstChar(title)) + '</span>' +
|
|
'</figure>'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 목록의 한 줄. 묶음 안에서의 순번을 쓴다.
|
|
* @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 summary = post && post.summary ? String(post.summary) : '';
|
|
const thumb = safeUrl(post && post.thumbnailUrl);
|
|
|
|
return (
|
|
'<li class="index__item">' +
|
|
'<a class="idx" href="#/post/' + postNo + '">' +
|
|
'<span class="idx__no">' + padNo(order) + '</span>' +
|
|
thumbHtml(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__date">' + escapeHtml(shortDate(post && post.createdAt)) + '</span>' +
|
|
'</span>' +
|
|
'</a>' +
|
|
'</li>'
|
|
);
|
|
}
|
|
|
|
/** 카테고리 묶음 하나 */
|
|
function groupHtml(group) {
|
|
const rows = group.posts.map((p, i) => rowHtml(p, i + 1)).join('');
|
|
return (
|
|
'<section class="group"' + (group.accent ? ' style="--card-accent:' + group.accent + '"' : '') + '>' +
|
|
'<h3 class="group__title">' +
|
|
'<span class="group__dot" aria-hidden="true"></span>' +
|
|
escapeHtml(group.name) +
|
|
'<span class="group__count">' + group.posts.length + '</span>' +
|
|
'</h3>' +
|
|
'<ol class="index">' + rows + '</ol>' +
|
|
'</section>'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 게시물을 카테고리별로 묶는다. 순서는 카테고리 목록의 순서(sortOrder)를 따른다.
|
|
* @param {Array} cats CategoryDto[]
|
|
* @param {Array} posts PostSummaryDto[]
|
|
*/
|
|
function groupByCategory(cats, posts) {
|
|
const rank = new Map();
|
|
(Array.isArray(cats) ? cats : []).forEach((c, i) => rank.set(toInt(c.categoryNo), i));
|
|
|
|
const buckets = new Map();
|
|
for (const p of posts) {
|
|
const key = toInt(p && p.categoryNo);
|
|
if (!buckets.has(key)) buckets.set(key, []);
|
|
buckets.get(key).push(p);
|
|
}
|
|
|
|
const out = [];
|
|
for (const [categoryNo, list] of buckets) {
|
|
const head = list[0] || {};
|
|
out.push({
|
|
categoryNo,
|
|
name: String(head.categoryName || '미분류'),
|
|
accent: safeAccent(head.categoryAccent),
|
|
posts: list
|
|
});
|
|
}
|
|
|
|
// 카테고리 목록에 없는 분류(삭제된 것 등)는 뒤로 보낸다.
|
|
out.sort((a, b) => {
|
|
const ra = rank.has(a.categoryNo) ? rank.get(a.categoryNo) : 9999;
|
|
const rb = rank.has(b.categoryNo) ? rank.get(b.categoryNo) : 9999;
|
|
return ra - rb;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** 화면 뼈대 (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>' +
|
|
'<div id="home-scope"></div>' +
|
|
'</div>' +
|
|
'<div id="home-groups"></div>' +
|
|
'<div id="home-state"></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;
|
|
|
|
outlet.insertAdjacentHTML('beforeend', SHELL_HTML);
|
|
|
|
const statsEl = outlet.querySelector('#home-stats');
|
|
const scopeEl = outlet.querySelector('#home-scope');
|
|
const groupsEl = outlet.querySelector('#home-groups');
|
|
const stateEl = outlet.querySelector('#home-state');
|
|
|
|
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 renderScope(show) {
|
|
scopeEl.innerHTML = show
|
|
? '<a class="btn btn--ghost btn--sm" href="#/">← 전체 작업</a>'
|
|
: '';
|
|
}
|
|
|
|
function showEmpty() {
|
|
groupsEl.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) {
|
|
groupsEl.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: () => { load(); }
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
/** 나눠 받은 페이지를 전부 모은다. 포트폴리오라 개수가 많지 않다. */
|
|
async function fetchAllPosts() {
|
|
const all = [];
|
|
let page = 1;
|
|
for (;;) {
|
|
const res = await api.posts.list({ category: activeSlug, page, size: FETCH_SIZE });
|
|
const items = res && Array.isArray(res.items) ? res.items : [];
|
|
all.push(...items);
|
|
const total = toInt(res && res.total);
|
|
if (!items.length || all.length >= total || page > 40) break;
|
|
page += 1;
|
|
}
|
|
return all;
|
|
}
|
|
|
|
async function load() {
|
|
stateEl.innerHTML = '';
|
|
groupsEl.innerHTML = '<ol class="index">' + skeletonRows(4) + '</ol>';
|
|
setBusy(true);
|
|
try {
|
|
const [cats, posts] = await Promise.all([
|
|
getCategories().catch(() => []),
|
|
fetchAllPosts()
|
|
]);
|
|
if (disposed) return;
|
|
|
|
renderStats(cats);
|
|
renderScope(activeSlug !== 'all');
|
|
|
|
if (!posts.length) {
|
|
showEmpty();
|
|
return;
|
|
}
|
|
|
|
groupsEl.innerHTML = groupByCategory(cats, posts).map(groupHtml).join('');
|
|
} catch (err) {
|
|
if (disposed) return;
|
|
showError(err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
load();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
setBusy(false);
|
|
};
|
|
}
|
|
|
|
export default view;
|