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:
이정수 2026-07-24 17:12:58 +09:00
parent 727b121888
commit 0b8da9d19f
4 changed files with 259 additions and 38 deletions

View File

@ -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 로 숨길 것.

View File

@ -1,7 +1,8 @@
<%@ page pageEncoding="UTF-8" %>
<%-- posts-empty.jspf — 포스팅 목록 빈 상태 파편
사용: posts-list.jsp의 posts.isEmpty() 분기에서 include
필요: bibimbap.css (.empty-state 클래스)
<%-- posts-empty-nocats.jspf — 포스팅 목록 빈 상태 (a): 활성 카테고리 0개(운영자 미생성)
사용: posts-list.jsp의 posts.isEmpty() && categories.isEmpty() 분기에서 include
필요: bibimbap.css(.empty-state/.btn/.empty-actions), 페이지 변수 canWrite
정적 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">
@ -13,11 +14,14 @@
<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>
<h3>아직 등록된 포스트가 없어요</h3>
<p>개발 일지, 팁, 질문 무엇이든 좋아요. 커뮤니티의 첫 글을 남겨보세요.</p>
<% if (canWrite) { %>
<h3>발행 섹션을 먼저 만들어 주세요</h3>
<p>카테고리를 등록하면 이곳에 편집 섹션이 열립니다.</p>
<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>
<% } else { %>
<h3>곧 새 소식으로 찾아뵙겠습니다</h3>
<p>편집팀이 첫 섹션을 준비하고 있어요.</p>
<% } %>
</div>

View File

@ -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>&lsquo;<%= HtmlUtils.htmlEscape(activeCategoryName) %>&rsquo; 섹션의 첫 편을 준비 중입니다</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>

View File

@ -6,6 +6,18 @@
<%@ page import="java.time.OffsetDateTime" %>
<%@ page import="java.util.Collections" %>
<%@ 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();
List<PostData> posts = Collections.emptyList();
@ -38,6 +50,12 @@
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--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"] {
color-scheme: dark;
@ -47,6 +65,12 @@
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
/* 다크: 어두운 종이 대비 밝은 값으로 상향 */
--cat-notice: #e8a54b;
--cat-devlog: #a9b8cc;
--cat-showcase: #e58a62;
--cat-resources: #93b187;
--cat-industry: #8fa9ce;
}
body {
margin: 0;
@ -93,30 +117,111 @@
text-decoration: none;
white-space: nowrap;
}
.posts-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
/* 편집 마스트헤드 인덱스 — 이 화면의 signature. 잡지 목차처럼 서수+라벨+활성 hue rule. */
.posts-tabs-scroll {
position: relative;
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 {
min-height: 2.25rem;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 0.95rem;
position: relative;
scroll-snap-align: start;
white-space: nowrap;
display: inline-flex;
align-items: center;
background: var(--card-bg);
color: var(--text);
font-size: 0.875rem;
font-weight: 800;
align-items: baseline;
gap: 0.4rem;
padding: 0.5rem 0.1rem;
background: none;
color: var(--text-muted);
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 {
border-color: rgba(232, 165, 75, 0.65);
background: var(--accent-soft);
color: var(--accent);
color: var(--hue, 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 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -151,18 +256,32 @@
flex-direction: column;
gap: 0.55rem;
}
/* 뱃지: 채운 amber pill 제거(전 카드 동일색 노이즈) → hue dot + 조용한 라벨 */
.post-card__category {
align-self: flex-start;
min-height: 1.6rem;
padding: 0 0.55rem;
border-radius: 8px;
display: inline-flex;
align-items: center;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.7rem;
font-weight: 900;
gap: 0.4rem;
background: none;
padding: 0;
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 {
margin: 0;
font-size: 1.1rem;
@ -200,6 +319,10 @@
font-weight: 900;
text-decoration: none;
}
@media (prefers-reduced-motion: reduce) {
.posts-tab { animation: none; }
.post-card:hover { transform: none; }
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -217,6 +340,9 @@
.posts-grid {
grid-template-columns: 1fr;
}
.posts-tabs {
gap: 1rem;
}
}
</style>
</head>
@ -233,18 +359,42 @@
<% } %>
</section>
<div class="posts-tabs-scroll">
<nav class="posts-tabs" aria-label="카테고리">
<a class="posts-tab <%= categoryId == null ? "is-active" : "" %>"
href="<%= ctx %>/posts">전체</a>
<% for (PostCategoryData cat : categories) { %>
<a class="posts-tab <%= categoryId != null && categoryId.equals(cat.getId()) ? "is-active" : "" %>"
href="<%= ctx %>/posts?categoryId=<%= cat.getId() %>"><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></a>
href="<%= ctx %>/posts"<%= categoryId == null ? " aria-current=\"page\"" : "" %>>
<span class="posts-tab__label">전체</span>
</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>
</div>
<% if (posts.isEmpty()) { %>
<%@ include file="fragments/posts-empty.jspf" %>
<% } else { %>
<% if (posts.isEmpty()) {
if (categories.isEmpty()) { %>
<%@ 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="포스트 목록">
<% for (PostData post : posts) { %>
<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">
<% } %>
<div class="post-card__body">
<% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %>
<span class="post-card__category"><%= HtmlUtils.htmlEscape(post.getCategoryName()) %></span>
<% String pCatTok = catToken(post.getCategorySlug());
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>
<span class="post-card__author"><%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %></span>