feat(posts): 기본 카테고리 5종 시드 + 카테고리별 목록 뷰 재설계
포스트 보드는 편집형 일방 발행 모델(Unity 블로그 참조)이나 post_categories 시드가 0건이라 목록 탭이 '전체' 하나만 렌더됐다. 기본 카테고리 5종(공지/개발일지/쇼케이스/자료·튜토리얼/업계 소식)을 멱등 마이그레이션(ON CONFLICT DO NOTHING)으로 시드하고, 목록 카테고리별 뷰 표현층을 재설계했다. - 카테고리 탭: '편집 마스트헤드 인덱스'로 재설계 — 서수 01~05(sort_order 근거) + 카테고리 hue 밑줄 rule + 로드 staggered reveal. 오버플로는 가로 스크롤(단일행 signature 보존), :focus-visible 링, aria-current='page' 추가. - 카드 뱃지: 전 카드 동일 amber pill 제거 → 카테고리 hue dot + 조용한 대문자 라벨. - 카테고리 hue 5색 토큰(저채도 인쇄물 팔레트, light/dark). slug는 catToken() 화이트리스트로 방출(임의 slug의 CSS 주입 차단). - 빈상태 2종 split: posts-empty-nocats(카테고리 0개)/posts-empty-noposts(특정 카테고리 글 0개). 편집 톤 카피, 소비자에겐 글쓰기 CTA 미노출(일방 발행 정합). 구 posts-empty.jspf 삭제. - 기능 로직(categoryId 필터·keyset 페이지네이션) 불변. JSP escape 유지, bibimbap.css 0변경, prefers-reduced-motion 존중. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqyLwTxF3FmW1eJCWN8yAo
This commit is contained in:
parent
727b121888
commit
0b8da9d19f
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- 포스트 보드 기본 카테고리 시드 — 편집형 일방 발행 taxonomy (Unity 5-mirror)
|
||||||
|
-- 배경: post_categories 는 운영자 CRUD 대상이나(D1/G2), 런칭 시 기본 탭이 곧바로
|
||||||
|
-- 렌더되도록 기본 5개 카테고리를 시드한다. 이후 운영자가 관리화면에서 추가/수정/
|
||||||
|
-- 비활성 가능. 카테고리 축은 Unity 공식 블로그(News/DevBlog/Games/Engine&platform/
|
||||||
|
-- Industry)를 미러링한 편집 taxonomy 다 — 포스트 보드가 제작자 상호 포럼이 아니라
|
||||||
|
-- 일방 발행/소비 모델이기 때문.
|
||||||
|
--
|
||||||
|
-- 이 프로젝트는 flyway/liquibase 가 없다(db/apply-local-ddl.sh 참고). 이 파일은
|
||||||
|
-- 작성만 한다. DB 적용은 orchestrator 가 사용자 확인 후 수행한다.
|
||||||
|
--
|
||||||
|
-- 멱등: slug UNIQUE(ux_post_categories_slug) 기준 ON CONFLICT DO NOTHING →
|
||||||
|
-- 재실행해도 중복 미생성. 기존에 동일 slug 가 있으면 건드리지 않는다(운영자 변경 보존).
|
||||||
|
--
|
||||||
|
-- 적용 (로컬 dev 스키마, 가동중 컨테이너):
|
||||||
|
-- docker exec -e PGOPTIONS="-c search_path=dev" -i bibimbap-db \
|
||||||
|
-- psql -U bibimbap -d bibimbap -v ON_ERROR_STOP=1 -f - < db/migrations/20260724-post-categories-seed.sql
|
||||||
|
-- 적용 (운영): 운영 스키마 search_path 로 동일 실행.
|
||||||
|
|
||||||
|
INSERT INTO post_categories (name, slug, sort_order, is_active) VALUES
|
||||||
|
('공지', 'notice', 1, true),
|
||||||
|
('개발일지', 'devlog', 2, true),
|
||||||
|
('쇼케이스', 'showcase', 3, true),
|
||||||
|
('자료·튜토리얼', 'resources', 4, true),
|
||||||
|
('업계 소식', 'industry', 5, true)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
-- 검증 (적용 후 위 slug 5건 전부 존재해야 함):
|
||||||
|
-- SELECT id, name, slug, sort_order, is_active FROM post_categories ORDER BY sort_order;
|
||||||
|
|
||||||
|
-- 롤백 (이 시드만 제거 — 운영자가 추가 생성한 카테고리는 보존):
|
||||||
|
-- DELETE FROM post_categories WHERE slug IN ('notice','devlog','showcase','resources','industry');
|
||||||
|
-- 주의: posts.category_id 는 NOT NULL FK 다. 해당 카테고리에 연결된 글이 있으면
|
||||||
|
-- FK 위반으로 삭제 불가 → 글을 다른 카테고리로 이관하거나 is_active=false 로 숨길 것.
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<%@ page pageEncoding="UTF-8" %>
|
<%@ page pageEncoding="UTF-8" %>
|
||||||
<%-- posts-empty.jspf — 포스팅 목록 빈 상태 파편
|
<%-- posts-empty-nocats.jspf — 포스팅 목록 빈 상태 (a): 활성 카테고리 0개(운영자 미생성)
|
||||||
사용: posts-list.jsp의 posts.isEmpty() 분기에서 include
|
사용: posts-list.jsp의 posts.isEmpty() && categories.isEmpty() 분기에서 include
|
||||||
필요: bibimbap.css (.empty-state 클래스)
|
필요: bibimbap.css(.empty-state/.btn/.empty-actions), 페이지 변수 canWrite
|
||||||
|
정적 include(<%@ include %>)라 부모 posts-list.jsp의 page import(HtmlUtils)·스코프 공유
|
||||||
--%>
|
--%>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" aria-hidden="true">
|
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" aria-hidden="true">
|
||||||
|
|
@ -13,11 +14,14 @@
|
||||||
<line x1="59" y1="51" x2="59" y2="63" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
<line x1="59" y1="51" x2="59" y2="63" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
<line x1="53" y1="57" x2="65" y2="57" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
<line x1="53" y1="57" x2="65" y2="57" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3>아직 등록된 포스트가 없어요</h3>
|
|
||||||
<p>개발 일지, 팁, 질문 무엇이든 좋아요. 커뮤니티의 첫 글을 남겨보세요.</p>
|
|
||||||
<% if (canWrite) { %>
|
<% if (canWrite) { %>
|
||||||
|
<h3>발행 섹션을 먼저 만들어 주세요</h3>
|
||||||
|
<p>카테고리를 등록하면 이곳에 편집 섹션이 열립니다.</p>
|
||||||
<div class="empty-actions">
|
<div class="empty-actions">
|
||||||
<a class="btn btn-primary" href="${pageContext.request.contextPath}/posts/new">+ 글쓰기</a>
|
<a class="btn btn-primary" href="${pageContext.request.contextPath}/admin/post-categories">카테고리 관리</a>
|
||||||
</div>
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<h3>곧 새 소식으로 찾아뵙겠습니다</h3>
|
||||||
|
<p>편집팀이 첫 섹션을 준비하고 있어요.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<%@ page pageEncoding="UTF-8" %>
|
||||||
|
<%-- posts-empty-noposts.jspf — 포스팅 목록 빈 상태 (b): 섹션은 있으나 발행된 글 0개
|
||||||
|
사용: posts-list.jsp의 posts.isEmpty() && !categories.isEmpty() 분기에서 include
|
||||||
|
필요: bibimbap.css(.empty-state/.btn/.empty-actions), 페이지 변수 canWrite, activeCategoryName(nullable)
|
||||||
|
정적 include(<%@ include %>)라 부모 posts-list.jsp의 page import(HtmlUtils)·스코프 공유
|
||||||
|
--%>
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" aria-hidden="true">
|
||||||
|
<rect x="20" y="13" width="44" height="57" rx="9" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
|
||||||
|
<line x1="30" y1="30" x2="54" y2="30" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<line x1="30" y1="40" x2="54" y2="40" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<line x1="30" y1="50" x2="45" y2="50" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="59" cy="57" r="13" fill="#D4A853"/>
|
||||||
|
<line x1="59" y1="51" x2="59" y2="63" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
<line x1="53" y1="57" x2="65" y2="57" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<% if (activeCategoryName != null) { %>
|
||||||
|
<h3>‘<%= HtmlUtils.htmlEscape(activeCategoryName) %>’ 섹션의 첫 편을 준비 중입니다</h3>
|
||||||
|
<% } else { %>
|
||||||
|
<h3>아직 발행된 글이 없습니다</h3>
|
||||||
|
<% } %>
|
||||||
|
<% if (canWrite) { %>
|
||||||
|
<p>이 섹션의 첫 편을 발행해 보세요.</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a class="btn btn-primary" href="${pageContext.request.contextPath}/posts/new">+ 새 글 발행</a>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p>편집팀의 첫 발행을 기다려 주세요.</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
@ -6,6 +6,18 @@
|
||||||
<%@ page import="java.time.OffsetDateTime" %>
|
<%@ page import="java.time.OffsetDateTime" %>
|
||||||
<%@ page import="java.util.Collections" %>
|
<%@ page import="java.util.Collections" %>
|
||||||
<%@ page import="java.util.List" %>
|
<%@ page import="java.util.List" %>
|
||||||
|
<%!
|
||||||
|
// slug: 서버가 준 카테고리 slug. 화이트리스트 통과 값만 data-cat 으로 반환, 그 외 "".
|
||||||
|
// 임의 slug 를 CSS 속성에 주입하지 않기 위한 XSS 안전 게이트.
|
||||||
|
private String catToken(String slug) {
|
||||||
|
if (slug == null) return "";
|
||||||
|
switch (slug) {
|
||||||
|
case "notice": case "devlog": case "showcase": case "resources": case "industry":
|
||||||
|
return slug;
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
%>
|
||||||
<%
|
<%
|
||||||
String ctx = request.getContextPath();
|
String ctx = request.getContextPath();
|
||||||
List<PostData> posts = Collections.emptyList();
|
List<PostData> posts = Collections.emptyList();
|
||||||
|
|
@ -38,6 +50,12 @@
|
||||||
--accent-soft: rgba(232, 165, 75, 0.16);
|
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||||
--border: rgba(0, 0, 0, 0.08);
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
--shadow: rgba(0, 0, 0, 0.06);
|
--shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
/* 카테고리 편집 섹션 색 — 저채도 인쇄물 팔레트(크림+amber 와 harmonize) */
|
||||||
|
--cat-notice: #b0741a;
|
||||||
|
--cat-devlog: #4c5a6e;
|
||||||
|
--cat-showcase: #bc5730;
|
||||||
|
--cat-resources: #5a7150;
|
||||||
|
--cat-industry: #3f5f8a;
|
||||||
}
|
}
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
@ -47,6 +65,12 @@
|
||||||
--text-muted: #a39e96;
|
--text-muted: #a39e96;
|
||||||
--border: rgba(255, 255, 255, 0.1);
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
--shadow: rgba(0, 0, 0, 0.35);
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
/* 다크: 어두운 종이 대비 밝은 값으로 상향 */
|
||||||
|
--cat-notice: #e8a54b;
|
||||||
|
--cat-devlog: #a9b8cc;
|
||||||
|
--cat-showcase: #e58a62;
|
||||||
|
--cat-resources: #93b187;
|
||||||
|
--cat-industry: #8fa9ce;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -93,30 +117,111 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.posts-tabs {
|
/* 편집 마스트헤드 인덱스 — 이 화면의 signature. 잡지 목차처럼 서수+라벨+활성 hue rule. */
|
||||||
display: flex;
|
.posts-tabs-scroll {
|
||||||
flex-wrap: wrap;
|
position: relative;
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
.posts-tabs-scroll::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--surface));
|
||||||
|
}
|
||||||
|
.posts-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.posts-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.posts-tab {
|
.posts-tab {
|
||||||
min-height: 2.25rem;
|
position: relative;
|
||||||
border: 1px solid var(--border);
|
scroll-snap-align: start;
|
||||||
border-radius: 999px;
|
white-space: nowrap;
|
||||||
padding: 0 0.95rem;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
background: var(--card-bg);
|
gap: 0.4rem;
|
||||||
color: var(--text);
|
padding: 0.5rem 0.1rem;
|
||||||
font-size: 0.875rem;
|
background: none;
|
||||||
font-weight: 800;
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
animation: tabIn 0.38s ease both;
|
||||||
|
}
|
||||||
|
.posts-tab__ordinal {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.posts-tab__label {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.posts-tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.posts-tab:hover::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--hue, var(--accent));
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
.posts-tab.is-active {
|
.posts-tab.is-active {
|
||||||
border-color: rgba(232, 165, 75, 0.65);
|
color: var(--hue, var(--accent));
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
.posts-tab.is-active .posts-tab__label {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.posts-tab.is-active .posts-tab__ordinal {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.posts-tab.is-active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--hue, var(--accent));
|
||||||
|
}
|
||||||
|
.posts-tab:focus-visible {
|
||||||
|
outline: 2px solid var(--hue, var(--accent));
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.posts-tab[data-cat="notice"] { --hue: var(--cat-notice); }
|
||||||
|
.posts-tab[data-cat="devlog"] { --hue: var(--cat-devlog); }
|
||||||
|
.posts-tab[data-cat="showcase"] { --hue: var(--cat-showcase); }
|
||||||
|
.posts-tab[data-cat="resources"] { --hue: var(--cat-resources); }
|
||||||
|
.posts-tab[data-cat="industry"] { --hue: var(--cat-industry); }
|
||||||
|
@keyframes tabIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.posts-tab:nth-child(1) { animation-delay: 0s; }
|
||||||
|
.posts-tab:nth-child(2) { animation-delay: 0.04s; }
|
||||||
|
.posts-tab:nth-child(3) { animation-delay: 0.08s; }
|
||||||
|
.posts-tab:nth-child(4) { animation-delay: 0.12s; }
|
||||||
|
.posts-tab:nth-child(5) { animation-delay: 0.16s; }
|
||||||
|
.posts-tab:nth-child(6) { animation-delay: 0.2s; }
|
||||||
|
.posts-tab:nth-child(7) { animation-delay: 0.24s; }
|
||||||
|
.posts-tab:nth-child(8) { animation-delay: 0.28s; }
|
||||||
.posts-grid {
|
.posts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
@ -151,18 +256,32 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
/* 뱃지: 채운 amber pill 제거(전 카드 동일색 노이즈) → hue dot + 조용한 라벨 */
|
||||||
.post-card__category {
|
.post-card__category {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
min-height: 1.6rem;
|
|
||||||
padding: 0 0.55rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--accent-soft);
|
gap: 0.4rem;
|
||||||
color: var(--accent);
|
background: none;
|
||||||
font-size: 0.7rem;
|
padding: 0;
|
||||||
font-weight: 900;
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
.post-card__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
background: var(--hue, var(--accent));
|
||||||
|
}
|
||||||
|
.post-card__category[data-cat="notice"] { --hue: var(--cat-notice); }
|
||||||
|
.post-card__category[data-cat="devlog"] { --hue: var(--cat-devlog); }
|
||||||
|
.post-card__category[data-cat="showcase"] { --hue: var(--cat-showcase); }
|
||||||
|
.post-card__category[data-cat="resources"] { --hue: var(--cat-resources); }
|
||||||
|
.post-card__category[data-cat="industry"] { --hue: var(--cat-industry); }
|
||||||
.post-card h2 {
|
.post-card h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|
@ -200,6 +319,10 @@
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.posts-tab { animation: none; }
|
||||||
|
.post-card:hover { transform: none; }
|
||||||
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.posts-grid {
|
.posts-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
@ -217,6 +340,9 @@
|
||||||
.posts-grid {
|
.posts-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.posts-tabs {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -233,18 +359,42 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="posts-tabs-scroll">
|
||||||
<nav class="posts-tabs" aria-label="카테고리">
|
<nav class="posts-tabs" aria-label="카테고리">
|
||||||
<a class="posts-tab <%= categoryId == null ? "is-active" : "" %>"
|
<a class="posts-tab <%= categoryId == null ? "is-active" : "" %>"
|
||||||
href="<%= ctx %>/posts">전체</a>
|
href="<%= ctx %>/posts"<%= categoryId == null ? " aria-current=\"page\"" : "" %>>
|
||||||
<% for (PostCategoryData cat : categories) { %>
|
<span class="posts-tab__label">전체</span>
|
||||||
<a class="posts-tab <%= categoryId != null && categoryId.equals(cat.getId()) ? "is-active" : "" %>"
|
</a>
|
||||||
href="<%= ctx %>/posts?categoryId=<%= cat.getId() %>"><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></a>
|
<%
|
||||||
|
int catIdx = 0;
|
||||||
|
for (PostCategoryData cat : categories) {
|
||||||
|
catIdx++;
|
||||||
|
boolean catOn = categoryId != null && categoryId.equals(cat.getId());
|
||||||
|
String catTok = catToken(cat.getSlug());
|
||||||
|
%>
|
||||||
|
<a class="posts-tab <%= catOn ? "is-active" : "" %>"<% if (!catTok.isEmpty()) { %> data-cat="<%= catTok %>"<% } %>
|
||||||
|
href="<%= ctx %>/posts?categoryId=<%= cat.getId() %>"<%= catOn ? " aria-current=\"page\"" : "" %>>
|
||||||
|
<span class="posts-tab__ordinal" aria-hidden="true"><%= String.format("%02d", catIdx) %></span>
|
||||||
|
<span class="posts-tab__label"><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></span>
|
||||||
|
</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% if (posts.isEmpty()) { %>
|
<% if (posts.isEmpty()) {
|
||||||
<%@ include file="fragments/posts-empty.jspf" %>
|
if (categories.isEmpty()) { %>
|
||||||
<% } else { %>
|
<%@ include file="fragments/posts-empty-nocats.jspf" %>
|
||||||
|
<% } else {
|
||||||
|
String activeCategoryName = null;
|
||||||
|
if (categoryId != null) {
|
||||||
|
for (PostCategoryData c : categories) {
|
||||||
|
if (categoryId.equals(c.getId())) { activeCategoryName = c.getName(); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<%@ include file="fragments/posts-empty-noposts.jspf" %>
|
||||||
|
<% }
|
||||||
|
} else { %>
|
||||||
<section class="posts-grid" aria-label="포스트 목록">
|
<section class="posts-grid" aria-label="포스트 목록">
|
||||||
<% for (PostData post : posts) { %>
|
<% for (PostData post : posts) { %>
|
||||||
<a class="post-card" href="<%= ctx %>/posts/<%= post.getId() %>">
|
<a class="post-card" href="<%= ctx %>/posts/<%= post.getId() %>">
|
||||||
|
|
@ -252,8 +402,12 @@
|
||||||
<img class="post-card__thumb" src="<%= HtmlUtils.htmlEscape(post.getOgImageUrl()) %>" alt="" loading="lazy">
|
<img class="post-card__thumb" src="<%= HtmlUtils.htmlEscape(post.getOgImageUrl()) %>" alt="" loading="lazy">
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="post-card__body">
|
<div class="post-card__body">
|
||||||
<% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %>
|
<% String pCatTok = catToken(post.getCategorySlug());
|
||||||
<span class="post-card__category"><%= HtmlUtils.htmlEscape(post.getCategoryName()) %></span>
|
if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %>
|
||||||
|
<span class="post-card__category"<% if (!pCatTok.isEmpty()) { %> data-cat="<%= pCatTok %>"<% } %>>
|
||||||
|
<span class="post-card__dot" aria-hidden="true"></span>
|
||||||
|
<%= HtmlUtils.htmlEscape(post.getCategoryName()) %>
|
||||||
|
</span>
|
||||||
<% } %>
|
<% } %>
|
||||||
<h2><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %></h2>
|
<h2><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %></h2>
|
||||||
<span class="post-card__author"><%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %></span>
|
<span class="post-card__author"><%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %></span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue