// views/home.js — 홈(작업 목록) 화면 // 라우트: #/ · #/?cat= // 구성: 히어로 → 카테고리별 묶음, 각 묶음 안에서 번호 매긴 목록 // // 날짜순으로 전부 섞어 보여 주지 않는다. 작업의 선후보다 분야가 중요해서 // 카테고리마다 제목을 달고 그 아래에 작업을 나열한다. // 모든 항목은 같은 크기다. 대신 행 간격을 넉넉히 두어 썸네일이 잘 보이게 한다. 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} */ 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 ( '
' + '' + escapeHtml(title) + '' + '
' ); } return ( '' ); } /** * 목록의 한 줄. 묶음 안에서의 순번을 쓴다. * @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 ( '
  • ' + '' + '' + padNo(order) + '' + thumbHtml(thumb, title) + '' + '' + escapeHtml(title) + '' + (summary ? '' + escapeHtml(summary) + '' : '') + '' + '' + '' + escapeHtml(shortDate(post && post.createdAt)) + '' + '' + '' + '
  • ' ); } /** 카테고리 묶음 하나 */ function groupHtml(group) { const rows = group.posts.map((p, i) => rowHtml(p, i + 1)).join(''); return ( '
    ' + '

    ' + '' + escapeHtml(group.name) + '' + group.posts.length + '' + '

    ' + '
      ' + rows + '
    ' + '
    ' ); } /** * 게시물을 카테고리별로 묶는다. 순서는 카테고리 목록의 순서(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 = '
    ' + '

    게임을 만듭니다

    ' + '
    ' + '
    ' + '
    ' + '
    ' + '

    작업

    ' + '
    ' + '
    ' + '
    ' + '
    ' + '
    '; /** * 홈 뷰. * @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 = '작업 ' + totalPosts + '' + '분야 ' + list.length + '' + 'Unity · .NET · MariaDB'; } /** 한 분야만 보고 있을 때 전체로 돌아가는 링크 */ function renderScope(show) { scopeEl.innerHTML = show ? '← 전체 작업' : ''; } function showEmpty() { groupsEl.innerHTML = ''; const filtered = activeSlug !== 'all'; stateEl.innerHTML = '
    ' + '

    ' + (filtered ? '이 분야에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '

    ' + '

    ' + (filtered ? '다른 분야도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '

    ' + (filtered ? '전체 보기' : '관리자') + '
    '; } function showError(err) { groupsEl.innerHTML = ''; stateEl.innerHTML = '
    ' + '

    작업 목록을 불러오지 못했습니다.

    ' + '

    ' + escapeHtml((err && err.message) || '알 수 없는 오류가 발생했습니다.') + '

    ' + '
    '; 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 = '
      ' + skeletonRows(4) + '
    '; 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;