From 0b8da9d19f5e3a03c46b3e39e3e6af39adb377a4 Mon Sep 17 00:00:00 2001 From: art Date: Fri, 24 Jul 2026 17:12:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(posts):=20=EA=B8=B0=EB=B3=B8=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=205=EC=A2=85=20=EC=8B=9C=EB=93=9C?= =?UTF-8?q?=20+=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=B3=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=B7=B0=20=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포스트 보드는 편집형 일방 발행 모델(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) Claude-Session: https://claude.ai/code/session_01UqyLwTxF3FmW1eJCWN8yAo --- .../20260724-post-categories-seed.sql | 33 +++ ...sts-empty.jspf => posts-empty-nocats.jspf} | 16 +- .../views/fragments/posts-empty-noposts.jspf | 30 +++ src/main/webapp/WEB-INF/views/posts-list.jsp | 218 +++++++++++++++--- 4 files changed, 259 insertions(+), 38 deletions(-) create mode 100644 db/migrations/20260724-post-categories-seed.sql rename src/main/webapp/WEB-INF/views/fragments/{posts-empty.jspf => posts-empty-nocats.jspf} (57%) create mode 100644 src/main/webapp/WEB-INF/views/fragments/posts-empty-noposts.jspf diff --git a/db/migrations/20260724-post-categories-seed.sql b/db/migrations/20260724-post-categories-seed.sql new file mode 100644 index 0000000..8560688 --- /dev/null +++ b/db/migrations/20260724-post-categories-seed.sql @@ -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 로 숨길 것. diff --git a/src/main/webapp/WEB-INF/views/fragments/posts-empty.jspf b/src/main/webapp/WEB-INF/views/fragments/posts-empty-nocats.jspf similarity index 57% rename from src/main/webapp/WEB-INF/views/fragments/posts-empty.jspf rename to src/main/webapp/WEB-INF/views/fragments/posts-empty-nocats.jspf index f697fb5..4632c24 100644 --- a/src/main/webapp/WEB-INF/views/fragments/posts-empty.jspf +++ b/src/main/webapp/WEB-INF/views/fragments/posts-empty-nocats.jspf @@ -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)·스코프 공유 --%>
-

아직 등록된 포스트가 없어요

-

개발 일지, 팁, 질문 무엇이든 좋아요. 커뮤니티의 첫 글을 남겨보세요.

<% if (canWrite) { %> +

발행 섹션을 먼저 만들어 주세요

+

카테고리를 등록하면 이곳에 편집 섹션이 열립니다.

+ <% } else { %> +

곧 새 소식으로 찾아뵙겠습니다

+

편집팀이 첫 섹션을 준비하고 있어요.

<% } %>
diff --git a/src/main/webapp/WEB-INF/views/fragments/posts-empty-noposts.jspf b/src/main/webapp/WEB-INF/views/fragments/posts-empty-noposts.jspf new file mode 100644 index 0000000..febc3e3 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/fragments/posts-empty-noposts.jspf @@ -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)·스코프 공유 +--%> +
+ + <% if (activeCategoryName != null) { %> +

‘<%= HtmlUtils.htmlEscape(activeCategoryName) %>’ 섹션의 첫 편을 준비 중입니다

+ <% } else { %> +

아직 발행된 글이 없습니다

+ <% } %> + <% if (canWrite) { %> +

이 섹션의 첫 편을 발행해 보세요.

+ + <% } else { %> +

편집팀의 첫 발행을 기다려 주세요.

+ <% } %> +
diff --git a/src/main/webapp/WEB-INF/views/posts-list.jsp b/src/main/webapp/WEB-INF/views/posts-list.jsp index f5cc579..5a9e211 100644 --- a/src/main/webapp/WEB-INF/views/posts-list.jsp +++ b/src/main/webapp/WEB-INF/views/posts-list.jsp @@ -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 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; + } } @@ -233,18 +359,42 @@ <% } %> +
+
- <% 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 { %>
<% for (PostData post : posts) { %> @@ -252,8 +402,12 @@ <% } %>
- <% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %> - <%= HtmlUtils.htmlEscape(post.getCategoryName()) %> + <% String pCatTok = catToken(post.getCategorySlug()); + if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %> + data-cat="<%= pCatTok %>"<% } %>> + + <%= HtmlUtils.htmlEscape(post.getCategoryName()) %> + <% } %>

<%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %>

<%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %>