나의 사이트 수정
This commit is contained in:
@@ -5,6 +5,13 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- 업로드된 사진은 배포 산출물에 넣지 않는다.
|
||||
서버에서는 볼륨(컨테이너) 또는 서버의 uploads 폴더로 따로 관리한다.
|
||||
빼지 않으면 dotnet publish 가 사진 수십 MB 를 함께 배포한다. -->
|
||||
<Content Remove="wwwroot/uploads/**" />
|
||||
<None Remove="wwwroot/uploads/**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.13">
|
||||
|
||||
@@ -448,209 +448,37 @@ em {
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
6. 인라인 카테고리 필터 — 칩이 아니라 글자
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.filter {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) 0;
|
||||
color: var(--text-mute);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: color var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.filter:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter.is-active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 활성 항목은 카테고리 색 밑줄 */
|
||||
.filter.is-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--card-accent);
|
||||
}
|
||||
|
||||
.filter__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-mute);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filter.is-active .filter__count {
|
||||
color: var(--card-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
7. 인덱스 목록 + 커서 썸네일
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
/* 대표 작업 — 목록 맨 앞 글을 크게 보여 준다.
|
||||
글이 하나뿐이어도 화면이 비어 보이지 않게 하는 역할. */
|
||||
.lead {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-6);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-7);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lead__media {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.lead__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 620ms var(--ease);
|
||||
}
|
||||
|
||||
.lead:hover .lead__media img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 첫 글자 */
|
||||
.lead__media--none,
|
||||
/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 제목 첫 글자 */
|
||||
.idx__thumb--none {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--surface-2);
|
||||
background-image:
|
||||
linear-gradient(140deg,
|
||||
color-mix(in srgb, var(--card-accent) 16%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card-accent) 18%, transparent) 0%,
|
||||
transparent 62%);
|
||||
color: color-mix(in srgb, var(--card-accent) 45%, var(--text-mute));
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lead__media--none {
|
||||
font-size: clamp(56px, 8vw, 104px);
|
||||
}
|
||||
|
||||
.idx__thumb--none {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* color-mix 미지원 브라우저 대비 */
|
||||
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
|
||||
.lead__media--none,
|
||||
.idx__thumb--none {
|
||||
background-image: none;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
}
|
||||
|
||||
.lead__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 한글은 자간을 크게 벌리면 글자가 흩어져 보인다. 살짝만 준다. */
|
||||
.lead__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.lead__label::before {
|
||||
content: "";
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--card-accent);
|
||||
}
|
||||
|
||||
.lead__cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
.lead__title {
|
||||
font-size: var(--step-3);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.2;
|
||||
word-break: keep-all;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lead__sum {
|
||||
font-size: var(--step-0);
|
||||
color: var(--text-dim);
|
||||
line-height: 1.75;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.lead__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.lead__more {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
color: var(--card-accent);
|
||||
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--card-accent) 32%, transparent);
|
||||
transition: box-shadow var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.lead__more::after {
|
||||
content: " →";
|
||||
}
|
||||
|
||||
.lead:hover .lead__more {
|
||||
box-shadow: inset 0 -2px 0 var(--card-accent);
|
||||
}
|
||||
|
||||
/* 인덱스 행의 작은 썸네일 */
|
||||
/* 인덱스 행의 썸네일 */
|
||||
.idx__thumb {
|
||||
margin: 0;
|
||||
width: 96px;
|
||||
width: 132px;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -671,6 +499,41 @@ em {
|
||||
border-color: color-mix(in srgb, var(--card-accent) 45%, var(--rule));
|
||||
}
|
||||
|
||||
/* 카테고리 묶음 — 분야 제목 + 그 아래 목록 */
|
||||
.group {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.group__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.group__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.index {
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
@@ -683,11 +546,12 @@ em {
|
||||
/* 행 하나. 번호 / 본문 / 메타 세 칸 그리드. */
|
||||
.idx {
|
||||
display: grid;
|
||||
/* 번호 · 썸네일 · 본문 · 메타 */
|
||||
grid-template-columns: 2.6rem auto 1fr auto;
|
||||
/* 번호 · 썸네일 · 본문 · 날짜 */
|
||||
grid-template-columns: 2.4rem auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
gap: var(--space-5);
|
||||
/* 썸네일이 잘 보이도록 위아래를 넉넉히 둔다 */
|
||||
padding: var(--space-5) 0;
|
||||
color: var(--text);
|
||||
transition: padding-left var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
@@ -1794,13 +1658,17 @@ a.sr-only:focus-visible {
|
||||
|
||||
/* 좁은 화면에서는 메타를 제목 아래로 내린다 */
|
||||
.idx {
|
||||
grid-template-columns: 2rem 64px 1fr;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: 2rem 88px 1fr;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.idx__thumb {
|
||||
width: 64px;
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.idx__meta {
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// views/home.js — 홈(작업 인덱스) 화면
|
||||
// views/home.js — 홈(작업 목록) 화면
|
||||
// 라우트: #/ · #/?cat=<slug>
|
||||
// 구성: 히어로 → 인라인 카테고리 필터 → 대표 작업 한 건 → 번호 매긴 인덱스
|
||||
// 구성: 히어로 → 카테고리별 묶음, 각 묶음 안에서 번호 매긴 목록
|
||||
//
|
||||
// 카드 그리드 대신 '작품집 목차' 형태를 쓰되, 이미지가 늘 자리를 갖도록
|
||||
// 맨 앞 글은 크게(lead) 보여 주고 나머지 행에는 작은 썸네일을 붙인다.
|
||||
// 이미지가 없는 글은 제목 첫 글자를 넣은 파스텔 판으로 대신한다.
|
||||
// 날짜순으로 전부 섞어 보여 주지 않는다. 작업의 선후보다 분야가 중요해서
|
||||
// 카테고리마다 제목을 달고 그 아래에 작업을 나열한다.
|
||||
// 모든 항목은 같은 크기다. 대신 행 간격을 넉넉히 두어 썸네일이 잘 보이게 한다.
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { el, escapeHtml, formatDate, setBusy, skeletonRows, toast } from '../ui.js';
|
||||
import { el, escapeHtml, formatDate, setBusy, skeletonRows } from '../ui.js';
|
||||
|
||||
/** 한 번에 불러오는 게시물 수 */
|
||||
const PAGE_SIZE = 20;
|
||||
/** 한 번에 긁어오는 게시물 수 (여러 번 나눠 받아 전부 모은다) */
|
||||
const FETCH_SIZE = 50;
|
||||
/** 카테고리 캐시 수명 (ms) */
|
||||
const CATEGORY_TTL_MS = 60000;
|
||||
|
||||
// 카테고리 목록은 모듈 스코프에 캐시한다.
|
||||
// 필터만 바뀌는 경우(#/ ↔ #/?cat=game) 뷰가 다시 마운트돼도 다시 요청하지 않는다.
|
||||
let catCache = null;
|
||||
let catCachedAt = 0;
|
||||
let catInflight = null;
|
||||
@@ -78,7 +76,7 @@ function padNo(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 'YYYY. MM. DD.' → 'YY.MM' (인덱스에서는 짧게 쓴다) */
|
||||
/** 'YYYY. MM. DD.' → 'YY.MM' */
|
||||
function shortDate(iso) {
|
||||
const full = formatDate(iso);
|
||||
const m = /^(\d{4})\.\s*(\d{2})\./.exec(full);
|
||||
@@ -92,98 +90,44 @@ function firstChar(title) {
|
||||
return Array.from(text)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판.
|
||||
* @param {string} cls 바깥 figure 의 클래스
|
||||
* @param {string} url 안전하게 걸러진 이미지 주소 ('' 가능)
|
||||
* @param {string} title alt / 대체 글자용
|
||||
*/
|
||||
function mediaHtml(cls, url, title) {
|
||||
/** 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판. */
|
||||
function thumbHtml(url, title) {
|
||||
if (url) {
|
||||
return (
|
||||
'<figure class="' + cls + '">' +
|
||||
'<figure class="idx__thumb">' +
|
||||
'<img src="' + escapeHtml(url) + '" alt="' + escapeHtml(title) + '"' +
|
||||
' loading="lazy" decoding="async">' +
|
||||
'</figure>'
|
||||
);
|
||||
}
|
||||
return (
|
||||
'<figure class="' + cls + ' ' + cls + '--none" aria-hidden="true">' +
|
||||
'<figure class="idx__thumb idx__thumb--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 부터)
|
||||
* @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 + '"' : '') + '>' +
|
||||
'<a class="idx" href="#/post/' + postNo + '">' +
|
||||
'<span class="idx__no">' + padNo(order) + '</span>' +
|
||||
mediaHtml('idx__thumb', thumb, title) +
|
||||
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__cat">' + escapeHtml(catName) + '</span>' +
|
||||
'<span class="idx__date">' + escapeHtml(shortDate(post && post.createdAt)) + '</span>' +
|
||||
'</span>' +
|
||||
'</a>' +
|
||||
@@ -191,6 +135,57 @@ function rowHtml(post, order) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 카테고리 묶음 하나 */
|
||||
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">' +
|
||||
@@ -200,12 +195,10 @@ const SHELL_HTML =
|
||||
'<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 id="home-scope"></div>' +
|
||||
'</div>' +
|
||||
'<div id="home-lead"></div>' +
|
||||
'<ol class="index" id="home-grid"></ol>' +
|
||||
'<div id="home-groups"></div>' +
|
||||
'<div id="home-state"></div>' +
|
||||
'<div id="home-more"></div>' +
|
||||
'</section>';
|
||||
|
||||
/**
|
||||
@@ -219,86 +212,44 @@ export async function view(params, outlet) {
|
||||
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 scopeEl = outlet.querySelector('#home-scope');
|
||||
const groupsEl = outlet.querySelector('#home-groups');
|
||||
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">분야 <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 renderScope(show) {
|
||||
scopeEl.innerHTML = show
|
||||
? '<a class="btn btn--ghost btn--sm" href="#/">← 전체 작업</a>'
|
||||
: '';
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
leadEl.innerHTML = '';
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.add('is-hidden');
|
||||
moreEl.innerHTML = '';
|
||||
groupsEl.innerHTML = '';
|
||||
const filtered = activeSlug !== 'all';
|
||||
stateEl.innerHTML =
|
||||
'<div class="empty">' +
|
||||
'<p>' + (filtered ? '이 카테고리에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '</p>' +
|
||||
'<p class="tagline">' + (filtered ? '다른 카테고리도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '</p>' +
|
||||
'<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 = '';
|
||||
groupsEl.innerHTML = '';
|
||||
stateEl.innerHTML =
|
||||
'<div class="empty">' +
|
||||
'<p>작업 목록을 불러오지 못했습니다.</p>' +
|
||||
@@ -311,61 +262,47 @@ export async function view(params, outlet) {
|
||||
class: 'btn btn--ghost',
|
||||
type: 'button',
|
||||
text: '다시 시도',
|
||||
onclick: () => { loadFirstPage(); }
|
||||
onclick: () => { load(); }
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 목록 ──────────────────────────────────────────────────────
|
||||
/** 첫 페이지: 맨 앞 글은 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);
|
||||
/** 나눠 받은 페이지를 전부 모은다. 포트폴리오라 개수가 많지 않다. */
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
async function load() {
|
||||
stateEl.innerHTML = '';
|
||||
groupsEl.innerHTML = '<ol class="index">' + skeletonRows(4) + '</ol>';
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.posts.list({ category: activeSlug, page: 1, size: PAGE_SIZE });
|
||||
const [cats, posts] = await Promise.all([
|
||||
getCategories().catch(() => []),
|
||||
fetchAllPosts()
|
||||
]);
|
||||
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) {
|
||||
|
||||
renderStats(cats);
|
||||
renderScope(activeSlug !== 'all');
|
||||
|
||||
if (!posts.length) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
drawFirst(items);
|
||||
renderMoreButton();
|
||||
|
||||
groupsEl.innerHTML = groupByCategory(cats, posts).map(groupHtml).join('');
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
showError(err);
|
||||
@@ -374,56 +311,10 @@ export async function view(params, outlet) {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
load();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
catsEl.removeEventListener('click', onCatsClick);
|
||||
setBusy(false);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user