From 26c48ebeebf6e51c5e2f05bf969f853a2437ddef Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:05:42 +0900 Subject: [PATCH 01/50] =?UTF-8?q?[feat]=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록/상세/글쓰기 페이지 shell과 라우트(/community, /community/:id, /community/write)를 추가하고 헤더 내비게이션에 커뮤니티 탭을 연결한다. 글쓰기는 PrivateRoute로 보호해 비로그인 접근 시 로그인 모달을 띄운다. Co-Authored-By: Claude Sonnet 5 --- src/App.jsx | 6 ++++++ src/components/layout/Header.jsx | 1 + src/constants/routes.js | 3 +++ src/pages/PostDetailPage.jsx | 27 +++++++++++++++++++++++++++ src/pages/PostDetailPage.module.css | 9 +++++++++ src/pages/PostWritePage.jsx | 22 ++++++++++++++++++++++ src/pages/PostWritePage.module.css | 21 +++++++++++++++++++++ src/pages/PostsPage.jsx | 22 ++++++++++++++++++++++ src/pages/PostsPage.module.css | 24 ++++++++++++++++++++++++ 9 files changed, 135 insertions(+) create mode 100644 src/pages/PostDetailPage.jsx create mode 100644 src/pages/PostDetailPage.module.css create mode 100644 src/pages/PostWritePage.jsx create mode 100644 src/pages/PostWritePage.module.css create mode 100644 src/pages/PostsPage.jsx create mode 100644 src/pages/PostsPage.module.css diff --git a/src/App.jsx b/src/App.jsx index f152479..c7a5e06 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -20,6 +20,9 @@ import ConcertDetailPage from '@/pages/ConcertDetailPage' import ConcertsPage from '@/pages/ConcertsPage' import ReleaseDetailPage from '@/pages/ReleaseDetailPage' import ReleasesPage from '@/pages/ReleasesPage' +import PostDetailPage from '@/pages/PostDetailPage' +import PostsPage from '@/pages/PostsPage' +import PostWritePage from '@/pages/PostWritePage' import HomePage from '@/pages/HomePage' import MyPage from '@/pages/MyPage' import NotFoundPage from '@/pages/NotFoundPage' @@ -65,6 +68,9 @@ function App() { } /> } /> 통합 검색} /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.jsx index 8da5090..72541db 100644 --- a/src/components/layout/Header.jsx +++ b/src/components/layout/Header.jsx @@ -15,6 +15,7 @@ const NAV_LINKS = [ { to: ROUTES.ARTISTS, label: '아티스트' }, { to: ROUTES.CALENDAR, label: '캘린더' }, { to: ROUTES.RELEASES, label: '음악' }, + { to: ROUTES.COMMUNITY, label: '커뮤니티' }, ] function Header() { diff --git a/src/constants/routes.js b/src/constants/routes.js index 218df76..e1dfc47 100644 --- a/src/constants/routes.js +++ b/src/constants/routes.js @@ -10,6 +10,9 @@ export const ROUTES = { RELEASE_DETAIL: (id) => `/releases/${id}`, CALENDAR: '/calendar', SEARCH: '/search', + COMMUNITY: '/community', + COMMUNITY_DETAIL: (id) => `/community/${id}`, + COMMUNITY_WRITE: '/community/write', ME: '/me', ME_UPCOMING: '/me/concerts/upcoming', ME_HISTORY: '/me/concerts/history', diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx new file mode 100644 index 0000000..7384fc9 --- /dev/null +++ b/src/pages/PostDetailPage.jsx @@ -0,0 +1,27 @@ +import { useParams } from 'react-router-dom' + +import BackButton from '@/components/ui/BackButton' +import usePageMeta from '@/hooks/usePageMeta' +import { ROUTES } from '@/constants/routes' +import styles from './PostDetailPage.module.css' + +function PostDetailPage() { + const { id } = useParams() + const postId = Number(id) + + usePageMeta({ + title: '커뮤니티 - 커밍', + description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', + path: `/community/${postId}`, + }) + + return ( +
+
+ +
+
+ ) +} + +export default PostDetailPage diff --git a/src/pages/PostDetailPage.module.css b/src/pages/PostDetailPage.module.css new file mode 100644 index 0000000..59122a7 --- /dev/null +++ b/src/pages/PostDetailPage.module.css @@ -0,0 +1,9 @@ +.page { + min-height: calc(100svh - var(--header-height)); + padding: 2.5rem 1.5rem 4rem; +} + +.inner { + max-width: 760px; + margin: 0 auto; +} diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx new file mode 100644 index 0000000..648a876 --- /dev/null +++ b/src/pages/PostWritePage.jsx @@ -0,0 +1,22 @@ +import usePageMeta from '@/hooks/usePageMeta' +import styles from './PostWritePage.module.css' + +function PostWritePage() { + usePageMeta({ + title: '글쓰기 - 커밍', + description: '공연 후기, 정보·제보, 자유 이야기를 남겨보세요.', + path: '/community/write', + }) + + return ( +
+
+
+

글쓰기

+
+
+
+ ) +} + +export default PostWritePage diff --git a/src/pages/PostWritePage.module.css b/src/pages/PostWritePage.module.css new file mode 100644 index 0000000..93675db --- /dev/null +++ b/src/pages/PostWritePage.module.css @@ -0,0 +1,21 @@ +.page { + min-height: calc(100svh - var(--header-height)); + padding: 2.5rem 1.5rem 4rem; +} + +.inner { + max-width: 760px; + margin: 0 auto; +} + +.pageHeader { + margin-bottom: 1.75rem; +} + +.pageTitle { + margin: 0; + font-size: 1.875rem; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; +} diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx new file mode 100644 index 0000000..c252982 --- /dev/null +++ b/src/pages/PostsPage.jsx @@ -0,0 +1,22 @@ +import usePageMeta from '@/hooks/usePageMeta' +import styles from './PostsPage.module.css' + +function PostsPage() { + usePageMeta({ + title: '커뮤니티 - 커밍', + description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', + path: '/community', + }) + + return ( +
+
+
+

커뮤니티

+
+
+
+ ) +} + +export default PostsPage diff --git a/src/pages/PostsPage.module.css b/src/pages/PostsPage.module.css new file mode 100644 index 0000000..905c019 --- /dev/null +++ b/src/pages/PostsPage.module.css @@ -0,0 +1,24 @@ +.page { + min-height: calc(100svh - var(--header-height)); + padding: 2.5rem 1.5rem 4rem; +} + +.inner { + max-width: var(--max-width); + margin: 0 auto; +} + +.pageHeader { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.75rem; +} + +.pageTitle { + margin: 0; + font-size: 1.875rem; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; +} From 361ef28c890c96c3853c1571eda56d887e653ee1 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:06:30 +0900 Subject: [PATCH 02/50] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=91=9C=EA=B8=B0=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24시간 이내 HH:mm / 올해 MM.dd / 해가 다르면 yy.MM.dd로 표기하는 formatPostDate 유틸과 카테고리(REVIEW/INFO/FREE)·멘션 타입 라벨·색상 상수를 추가한다. Co-Authored-By: Claude Sonnet 5 --- src/constants/post.js | 20 ++++++++++++++++++++ src/utils/date.js | 16 ++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/constants/post.js diff --git a/src/constants/post.js b/src/constants/post.js new file mode 100644 index 0000000..3533080 --- /dev/null +++ b/src/constants/post.js @@ -0,0 +1,20 @@ +export const POST_CATEGORY_LABEL = { + REVIEW: '후기', + INFO: '정보·제보', + FREE: '자유', +} + +export const POST_CATEGORY_COLOR = { + REVIEW: '#7C3AED', + INFO: '#0284C7', + FREE: '#16A34A', +} + +// 후기·정보는 엔티티 멘션 최소 1개 필수 +export const CATEGORIES_REQUIRING_MENTION = ['REVIEW', 'INFO'] + +export const MENTION_TYPE_LABEL = { + CONCERT: '공연', + ARTIST: '아티스트', + RELEASE: '음악', +} diff --git a/src/utils/date.js b/src/utils/date.js index 15eb214..5398191 100644 --- a/src/utils/date.js +++ b/src/utils/date.js @@ -31,3 +31,19 @@ export function formatRelativeDate(isoStr) { if (diff < 28) return `${Math.floor(diff / 7)}주 전` return `${Math.floor(diff / 30)}개월 전` } + +// 게시글 목록·백링크용 날짜 표기 정책 (고정 시각 표기 — 재렌더 없이도 값이 낡지 않음) +// 24시간 이내 → 'HH:mm', 올해 → 'MM.dd', 해가 다르면 → 'yy.MM.dd' +export function formatPostDate(isoStr) { + if (!isoStr) return '' + const date = new Date(isoStr) + const now = new Date() + + if (now - date < 86400000) { + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + } + if (date.getFullYear() === now.getFullYear()) { + return `${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}` + } + return `${String(date.getFullYear()).slice(-2)}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}` +} From 2bcc34fbd86a14ea80a1fca1b71bdc76949a71a1 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:10:05 +0900 Subject: [PATCH 03/50] =?UTF-8?q?[feat]=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카테고리 필터(전체/후기/정보·제보/자유), 조회수·추천수·날짜를 표시하는 PostListItem, 페이지네이션을 갖춘 목록 페이지를 mock 데이터로 완성한다. 비로그인 상태에서 글쓰기 클릭 시 로그인 모달을 띄운다. Co-Authored-By: Claude Sonnet 5 --- src/components/post/PostListItem.jsx | 42 +++++++ src/components/post/PostListItem.module.css | 83 ++++++++++++++ src/pages/PostsPage.jsx | 118 ++++++++++++++++++++ src/pages/PostsPage.module.css | 72 +++++++++++- 4 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/components/post/PostListItem.jsx create mode 100644 src/components/post/PostListItem.module.css diff --git a/src/components/post/PostListItem.jsx b/src/components/post/PostListItem.jsx new file mode 100644 index 0000000..e5a27a8 --- /dev/null +++ b/src/components/post/PostListItem.jsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router-dom' + +import { ROUTES } from '@/constants/routes' +import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' +import { formatPostDate } from '@/utils/date' +import styles from './PostListItem.module.css' + +function PostListItem({ post }) { + const { id, category, title, authorNickname, recommendCount, viewCount, createdAt } = post + + return ( + +
+ + {POST_CATEGORY_LABEL[category] ?? category} + + {title} +
+
+ {authorNickname ?? '탈퇴 회원'} + + {formatPostDate(createdAt)} + + + + {viewCount.toLocaleString()} + + + + {recommendCount.toLocaleString()} + +
+ + ) +} + +export default PostListItem diff --git a/src/components/post/PostListItem.module.css b/src/components/post/PostListItem.module.css new file mode 100644 index 0000000..23dc427 --- /dev/null +++ b/src/components/post/PostListItem.module.css @@ -0,0 +1,83 @@ +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0.25rem; + border-bottom: 1px solid var(--color-border); + color: inherit; + transition: background-color 0.15s; +} + +.row:hover { + background: var(--color-surface-hover); +} + +.main { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; + flex: 1; +} + +.badge { + flex-shrink: 0; + padding: 0.2em 0.55em; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--color-on-accent); + border-radius: var(--radius-xs); + line-height: 1.4; +} + +.title { + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.meta { + display: flex; + align-items: center; + gap: 0.375rem; + flex-shrink: 0; + font-size: 0.8125rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.dot { + opacity: 0.6; +} + +.stat { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +@media (max-width: 767px) { + .row { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .meta { + align-self: stretch; + } + + .author { + display: none; + } + + .dot:first-of-type { + display: none; + } +} diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index c252982..c31dd9f 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -1,19 +1,137 @@ +import { useNavigate, useSearchParams } from 'react-router-dom' + +import EmptyState from '@/components/ui/EmptyState' +import Pagination from '@/components/ui/Pagination' +import PostListItem from '@/components/post/PostListItem' import usePageMeta from '@/hooks/usePageMeta' +import { ROUTES } from '@/constants/routes' +import { POST_CATEGORY_LABEL } from '@/constants/post' +import useAuthStore from '@/stores/authStore' +import useLoginModalStore from '@/stores/loginModalStore' import styles from './PostsPage.module.css' +const CATEGORY_FILTERS = ['ALL', 'REVIEW', 'INFO', 'FREE'] +const ITEMS_PER_PAGE = 10 + +// TODO: API 연동 후 제거 — GET /api/posts 배포 완료 시 실 데이터로 교체 +const MOCK_POST_LIST = [ + { id: 10, category: 'REVIEW', title: '요네즈 켄시 내한 첫 공연, 앙코르만 3번', authorNickname: 'moonlit_haze', recommendCount: 128, viewCount: 3204, createdAt: '2026-09-10T09:14:00' }, + { id: 9, category: 'INFO', title: 'KSPO DOME 좌석 시야 후기 모아봤어요 (2·3층 위주)', authorNickname: 'tteok_and_roll', recommendCount: 41, viewCount: 1850, createdAt: '2026-09-10T02:40:00' }, + { id: 8, category: 'FREE', title: '이번 투어 응모 결과 다들 어떠셨나요', authorNickname: 'nagoya_express', recommendCount: 9, viewCount: 640, createdAt: '2026-09-09T21:05:00' }, + { id: 7, category: 'REVIEW', title: 'LOST CORNER 발매 기념 감상평 남깁니다', authorNickname: 'hanare_bito', recommendCount: 63, viewCount: 2410, createdAt: '2026-09-06T18:30:00' }, + { id: 6, category: 'INFO', title: '공연장 근처 굿즈 부스 운영시간 정리', authorNickname: 'yoruno_neko', recommendCount: 22, viewCount: 1120, createdAt: '2026-09-04T11:00:00' }, + { id: 5, category: 'FREE', title: '내한 소식 듣고 바로 연차 냈습니다', authorNickname: 'string_hoshi', recommendCount: 17, viewCount: 980, createdAt: '2026-08-22T08:12:00' }, + { id: 4, category: 'REVIEW', title: '2025년 마지막 공연 회고', authorNickname: 'moonlit_haze', recommendCount: 54, viewCount: 1760, createdAt: '2025-12-30T20:15:00' }, + { id: 3, category: 'INFO', title: '작년 굿즈 라인업 아카이브 (사진 有)', authorNickname: 'tteok_and_roll', recommendCount: 30, viewCount: 2050, createdAt: '2025-11-02T13:45:00' }, + { id: 2, category: 'FREE', title: '입덕 계기가 뭐였나요', authorNickname: 'hanare_bito', recommendCount: 12, viewCount: 730, createdAt: '2025-09-18T09:00:00' }, + { id: 1, category: 'REVIEW', title: 'KICK BACK 오프닝 직캠 후기', authorNickname: null, recommendCount: 5, viewCount: 410, createdAt: '2025-08-15T19:20:00' }, + ...Array.from({ length: 14 }, (_, i) => ({ + id: -1 - i, + category: CATEGORY_FILTERS[1 + (i % 3)], + title: `아카이브 게시글 ${i + 1}`, + authorNickname: `user_${i + 1}`, + recommendCount: (i * 7) % 40, + viewCount: 100 + i * 63, + createdAt: `2025-0${1 + (i % 6)}-1${i % 9}T10:00:00`, + })), +] + function PostsPage() { + const [searchParams, setSearchParams] = useSearchParams() + const navigate = useNavigate() + const user = useAuthStore((s) => s.user) + const openLoginModal = useLoginModalStore((s) => s.open) + + const selectedCategory = CATEGORY_FILTERS.includes(searchParams.get('category')) + ? searchParams.get('category') + : 'ALL' + const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10)) + usePageMeta({ title: '커뮤니티 - 커밍', description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', path: '/community', }) + function handleCategoryChange(category) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + if (category === 'ALL') next.delete('category') + else next.set('category', category) + next.delete('page') + return next + }) + window.scrollTo(0, 0) + } + + function handlePageChange(page) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + if (page === 1) next.delete('page') + else next.set('page', String(page)) + return next + }) + window.scrollTo(0, 0) + } + + function handleWriteClick() { + if (!user) { + openLoginModal(ROUTES.COMMUNITY_WRITE) + return + } + navigate(ROUTES.COMMUNITY_WRITE) + } + + const filtered = selectedCategory === 'ALL' + ? MOCK_POST_LIST + : MOCK_POST_LIST.filter((p) => p.category === selectedCategory) + const totalElements = filtered.length + const totalPages = Math.max(1, Math.ceil(totalElements / ITEMS_PER_PAGE)) + const posts = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE) + return (
+

커뮤니티

+ +
+ +
+ {CATEGORY_FILTERS.map((c) => ( + + ))}
+ + {posts.length > 0 ? ( +
+ {posts.map((post) => ( + + ))} +
+ ) : ( + + )} + + {totalPages > 1 && ( + + )} +
) diff --git a/src/pages/PostsPage.module.css b/src/pages/PostsPage.module.css index 905c019..a4ac1c5 100644 --- a/src/pages/PostsPage.module.css +++ b/src/pages/PostsPage.module.css @@ -4,15 +4,17 @@ } .inner { - max-width: var(--max-width); + max-width: 840px; margin: 0 auto; } +/* ── 페이지 헤더 ── */ .pageHeader { display: flex; align-items: center; + justify-content: space-between; gap: 0.75rem; - margin-bottom: 1.75rem; + margin-bottom: 1.5rem; } .pageTitle { @@ -22,3 +24,69 @@ color: var(--color-text); letter-spacing: -0.02em; } + +.writeBtn { + flex-shrink: 0; + padding: 0.5rem 1.125rem; + border-radius: var(--radius-lg); + background: var(--color-accent); + color: var(--color-on-accent); + font-size: 0.875rem; + font-weight: 600; + transition: background-color 0.15s; +} + +.writeBtn:hover { + background: var(--color-accent-dark); +} + +/* ── 카테고리 필터 ── */ +.filterBar { + display: flex; + gap: 0.5rem; + overflow-x: auto; + padding-bottom: 0.25rem; + margin-bottom: 0.5rem; + scrollbar-width: none; +} + +.filterBar::-webkit-scrollbar { + display: none; +} + +.filterTab { + flex-shrink: 0; + padding: 0.375rem 1rem; + border-radius: var(--radius-full); + font-size: 0.875rem; + font-weight: 500; + border: 1.5px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + transition: border-color 0.15s, color 0.15s, background-color 0.15s; + white-space: nowrap; +} + +.filterTab:hover { + border-color: var(--color-accent-border); + color: var(--color-accent); +} + +.filterTabActive { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-on-accent); + font-weight: 600; + box-shadow: 0 2px 8px var(--color-accent-shadow); +} + +.filterTabActive:hover { + color: var(--color-on-accent); +} + +/* ── 목록 ── */ +.list { + display: flex; + flex-direction: column; + border-top: 1px solid var(--color-border); +} From 21b11f9c44b0a5f324df046c2c6713f2b9820e3c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:14:27 +0900 Subject: [PATCH 04/50] =?UTF-8?q?[feat]=20Tiptap=20=EC=97=90=EB=94=94?= =?UTF-8?q?=ED=84=B0=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20entityMention=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EB=85=B8=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @tiptap/react 기반 리치 텍스트 에디터를 글쓰기 페이지에 연결하고, 공연·음악(발매)은 블록 카드(entityMentionCard), 아티스트는 인라인 칩(entityMentionChip)으로 렌더링하는 커스텀 노드를 추가한다. 좌측 색상 스트라이프 대신 엔티티별 실제 썸네일 형태(세로 포스터/ 정사각 앨범아트/원형 아바타)로 타입을 구분한다. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 624 +++++++++++++++++- package.json | 4 + .../post/editor/EntityMentionCard.module.css | 121 ++++ .../post/editor/EntityMentionCardView.jsx | 30 + .../post/editor/EntityMentionChipView.jsx | 19 + src/components/post/editor/PostEditor.jsx | 48 ++ .../post/editor/PostEditor.module.css | 59 ++ .../post/editor/entityMentionExtensions.js | 62 ++ src/pages/PostWritePage.jsx | 6 + 9 files changed, 970 insertions(+), 3 deletions(-) create mode 100644 src/components/post/editor/EntityMentionCard.module.css create mode 100644 src/components/post/editor/EntityMentionCardView.jsx create mode 100644 src/components/post/editor/EntityMentionChipView.jsx create mode 100644 src/components/post/editor/PostEditor.jsx create mode 100644 src/components/post/editor/PostEditor.module.css create mode 100644 src/components/post/editor/entityMentionExtensions.js diff --git a/package-lock.json b/package-lock.json index 1c2bf64..f4d3544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.96.2", + "@tiptap/core": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "axios": "^1.14.0", @@ -969,6 +973,436 @@ "react": "^18 || ^19" } }, + "node_modules/@tiptap/core": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -998,7 +1432,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1008,12 +1441,17 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vercel/analytics": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", @@ -1426,7 +1864,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/date-fns": { @@ -1762,6 +2199,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz", + "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2510,6 +2956,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2646,6 +3098,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2817,6 +3275,145 @@ "node": ">= 0.8.0" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2967,6 +3564,12 @@ "dev": true, "license": "MIT" }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3133,6 +3736,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -3211,6 +3823,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f38f8a0..b37f391 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ }, "dependencies": { "@tanstack/react-query": "^5.96.2", + "@tiptap/core": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "axios": "^1.14.0", diff --git a/src/components/post/editor/EntityMentionCard.module.css b/src/components/post/editor/EntityMentionCard.module.css new file mode 100644 index 0000000..efb059a --- /dev/null +++ b/src/components/post/editor/EntityMentionCard.module.css @@ -0,0 +1,121 @@ +.card { + display: flex; + max-width: 360px; + margin: 0.875rem 0 1.25rem; + padding: 0.6875rem 0.875rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + cursor: default; + transition: border-color 0.15s ease; +} + +.card:hover { + border-color: var(--color-text-muted); +} + +.thumb { + flex-shrink: 0; + background-color: var(--color-surface-hover); + background-size: cover; + background-position: center; +} + +.txt { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + justify-content: center; +} + +.type { + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.name { + font-size: 0.906rem; + font-weight: 700; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sub { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chev { + margin-left: auto; + align-self: center; + color: var(--color-text-muted); + flex-shrink: 0; + padding-left: 0.5rem; +} + +/* ── 공연: 세로 포스터 (2:3) ── */ +.concert { + align-items: flex-start; + gap: 0.75rem; +} + +.concert .thumb { + width: 54px; + height: 78px; + border-radius: var(--radius-sm); +} + +.concert .txt { + padding-top: 0.125rem; + gap: 0.25rem; +} + +/* ── 음악(발매): 정사각 앨범 아트 (1:1) ── */ +.release { + align-items: center; + gap: 0.75rem; +} + +.release .thumb { + width: 48px; + height: 48px; + border-radius: var(--radius-sm); +} + +/* ── 아티스트: 원형 아바타 인라인 칩 ── */ +.artist { + display: inline-flex; + align-items: center; + gap: 0.375rem; + max-width: none; + width: auto; + margin: 0 0.0625rem; + padding: 0.125rem 0.625rem 0.125rem 0.1875rem; + border-radius: var(--radius-full); + vertical-align: middle; +} + +.artist:hover { + border-color: var(--color-border); + background: var(--color-surface-hover); +} + +.artist .thumb { + width: 22px; + height: 22px; + border-radius: 50%; +} + +.artist .name { + font-size: inherit; + font-weight: 700; +} diff --git a/src/components/post/editor/EntityMentionCardView.jsx b/src/components/post/editor/EntityMentionCardView.jsx new file mode 100644 index 0000000..7094483 --- /dev/null +++ b/src/components/post/editor/EntityMentionCardView.jsx @@ -0,0 +1,30 @@ +import { NodeViewWrapper } from '@tiptap/react' + +import { MENTION_TYPE_LABEL } from '@/constants/post' +import styles from './EntityMentionCard.module.css' + +function EntityMentionCardView({ node }) { + const { entityType, title, subtitle, thumbnailUrl } = node.attrs + const variantClass = entityType === 'CONCERT' ? styles.concert : styles.release + + return ( + +
+
+ {MENTION_TYPE_LABEL[entityType]} + {title} + {subtitle && {subtitle}} +
+ + + ) +} + +export default EntityMentionCardView diff --git a/src/components/post/editor/EntityMentionChipView.jsx b/src/components/post/editor/EntityMentionChipView.jsx new file mode 100644 index 0000000..c08403c --- /dev/null +++ b/src/components/post/editor/EntityMentionChipView.jsx @@ -0,0 +1,19 @@ +import { NodeViewWrapper } from '@tiptap/react' + +import styles from './EntityMentionCard.module.css' + +function EntityMentionChipView({ node }) { + const { title, thumbnailUrl } = node.attrs + + return ( + + + {title} + + ) +} + +export default EntityMentionChipView diff --git a/src/components/post/editor/PostEditor.jsx b/src/components/post/editor/PostEditor.jsx new file mode 100644 index 0000000..a533d7f --- /dev/null +++ b/src/components/post/editor/PostEditor.jsx @@ -0,0 +1,48 @@ +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' + +import { EntityMentionCard, EntityMentionChip } from './entityMentionExtensions' +import styles from './PostEditor.module.css' + +function PostEditor({ content, onChange }) { + const editor = useEditor({ + extensions: [StarterKit, EntityMentionCard, EntityMentionChip], + content, + onUpdate: ({ editor: e }) => onChange?.(e.getJSON()), + editorProps: { + attributes: { + class: styles.prose, + }, + }, + }) + + if (!editor) return null + + return ( +
+
+ + +
+ +
+ ) +} + +export default PostEditor diff --git a/src/components/post/editor/PostEditor.module.css b/src/components/post/editor/PostEditor.module.css new file mode 100644 index 0000000..f7bb128 --- /dev/null +++ b/src/components/post/editor/PostEditor.module.css @@ -0,0 +1,59 @@ +.wrap { + border: 1.5px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + overflow: hidden; +} + +.wrap:focus-within { + border-color: var(--color-accent); +} + +.toolbar { + display: flex; + gap: 0.25rem; + padding: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.toolBtn, +.toolBtnActive { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + background: none; + border: none; + font-size: 0.9375rem; + color: var(--color-text-muted); + transition: background-color 0.15s, color 0.15s; +} + +.toolBtn:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.toolBtnActive { + background: var(--color-accent-dim); + color: var(--color-accent); +} + +.prose { + min-height: 320px; + padding: 1rem 1.125rem 1.5rem; + font-size: 0.9375rem; + line-height: 1.75; + color: var(--color-text); + outline: none; +} + +.prose p { + margin: 0 0 0.75rem; +} + +.prose p:last-child { + margin-bottom: 0; +} diff --git a/src/components/post/editor/entityMentionExtensions.js b/src/components/post/editor/entityMentionExtensions.js new file mode 100644 index 0000000..e0d84b0 --- /dev/null +++ b/src/components/post/editor/entityMentionExtensions.js @@ -0,0 +1,62 @@ +import { Node, mergeAttributes } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' + +import EntityMentionCardView from './EntityMentionCardView' +import EntityMentionChipView from './EntityMentionChipView' + +function entityAttributes() { + return { + entityType: { default: null }, + entityId: { default: null }, + title: { default: '' }, + subtitle: { default: null }, + thumbnailUrl: { default: null }, + } +} + +// 공연·음악(발매) 멘션 — 세로 포스터/정사각 앨범아트 블록 카드로 문단 흐름을 끊고 삽입된다 +export const EntityMentionCard = Node.create({ + name: 'entityMentionCard', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes: entityAttributes, + + parseHTML() { + return [{ tag: 'div[data-entity-mention-card]' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-entity-mention-card': '' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(EntityMentionCardView) + }, +}) + +// 아티스트 멘션 — 원형 아바타 인라인 칩으로 문장 중간에 자연스럽게 삽입된다 +export const EntityMentionChip = Node.create({ + name: 'entityMentionChip', + group: 'inline', + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes: entityAttributes, + + parseHTML() { + return [{ tag: 'span[data-entity-mention-chip]' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes(HTMLAttributes, { 'data-entity-mention-chip': '' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(EntityMentionChipView) + }, +}) diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx index 648a876..838ac5c 100644 --- a/src/pages/PostWritePage.jsx +++ b/src/pages/PostWritePage.jsx @@ -1,7 +1,12 @@ +import { useState } from 'react' + +import PostEditor from '@/components/post/editor/PostEditor' import usePageMeta from '@/hooks/usePageMeta' import styles from './PostWritePage.module.css' function PostWritePage() { + const [contentJson, setContentJson] = useState(null) + usePageMeta({ title: '글쓰기 - 커밍', description: '공연 후기, 정보·제보, 자유 이야기를 남겨보세요.', @@ -14,6 +19,7 @@ function PostWritePage() {

글쓰기

+
) From a49cf0351448992f7f003e8e99a9dee3a30c7bad Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:21:46 +0900 Subject: [PATCH 05/50] =?UTF-8?q?[feat]=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20?= =?UTF-8?q?=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=A9=98=EC=85=98=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '/공연 vaundy' 형태의 슬래시 커맨드로 타입 선택 → 검색어 입력 → 결과 선택까지 이어지는 @tiptap/suggestion 기반 드롭다운을 추가한다. 검색 결과 0건이면 선택이 불가능해 멘션 작성이 자연스럽게 차단된다. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 16 ++++ package.json | 1 + src/components/post/editor/PostEditor.jsx | 4 +- .../post/editor/PostEditor.module.css | 8 ++ .../post/editor/SlashCommandMenu.jsx | 76 +++++++++++++++ .../post/editor/SlashCommandMenu.module.css | 94 +++++++++++++++++++ src/components/post/editor/mentionSearch.js | 52 ++++++++++ src/components/post/editor/slashCommand.jsx | 89 ++++++++++++++++++ 8 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 src/components/post/editor/SlashCommandMenu.jsx create mode 100644 src/components/post/editor/SlashCommandMenu.module.css create mode 100644 src/components/post/editor/mentionSearch.js create mode 100644 src/components/post/editor/slashCommand.jsx diff --git a/package-lock.json b/package-lock.json index f4d3544..8eda7ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", + "@tiptap/suggestion": "^3.31.3", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "axios": "^1.14.0", @@ -1403,6 +1404,21 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.31.3.tgz", + "integrity": "sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/package.json b/package.json index b37f391..54eb15f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", + "@tiptap/suggestion": "^3.31.3", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "axios": "^1.14.0", diff --git a/src/components/post/editor/PostEditor.jsx b/src/components/post/editor/PostEditor.jsx index a533d7f..1cae645 100644 --- a/src/components/post/editor/PostEditor.jsx +++ b/src/components/post/editor/PostEditor.jsx @@ -2,11 +2,12 @@ import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { EntityMentionCard, EntityMentionChip } from './entityMentionExtensions' +import SlashCommand from './slashCommand' import styles from './PostEditor.module.css' function PostEditor({ content, onChange }) { const editor = useEditor({ - extensions: [StarterKit, EntityMentionCard, EntityMentionChip], + extensions: [StarterKit, EntityMentionCard, EntityMentionChip, SlashCommand], content, onUpdate: ({ editor: e }) => onChange?.(e.getJSON()), editorProps: { @@ -39,6 +40,7 @@ function PostEditor({ content, onChange }) { > I + / 를 입력해 공연·아티스트·음악을 태그해보세요 diff --git a/src/components/post/editor/PostEditor.module.css b/src/components/post/editor/PostEditor.module.css index f7bb128..1da4abf 100644 --- a/src/components/post/editor/PostEditor.module.css +++ b/src/components/post/editor/PostEditor.module.css @@ -41,6 +41,14 @@ color: var(--color-accent); } +.hint { + display: flex; + align-items: center; + margin-left: 0.5rem; + font-size: 0.75rem; + color: var(--color-text-muted); +} + .prose { min-height: 320px; padding: 1rem 1.125rem 1.5rem; diff --git a/src/components/post/editor/SlashCommandMenu.jsx b/src/components/post/editor/SlashCommandMenu.jsx new file mode 100644 index 0000000..26de91c --- /dev/null +++ b/src/components/post/editor/SlashCommandMenu.jsx @@ -0,0 +1,76 @@ +import { forwardRef, useImperativeHandle, useState } from 'react' + +import styles from './SlashCommandMenu.module.css' + +const SlashCommandMenu = forwardRef(function SlashCommandMenu({ items, command }, ref) { + const [selectedIndex, setSelectedIndex] = useState(0) + const [prevItems, setPrevItems] = useState(items) + + if (items !== prevItems) { + setPrevItems(items) + setSelectedIndex(0) + } + + function selectItem(index) { + const item = items[index] + if (item) command(item) + } + + useImperativeHandle(ref, () => ({ + onKeyDown({ event }) { + if (items.length === 0) return false + if (event.key === 'ArrowUp') { + setSelectedIndex((i) => (i + items.length - 1) % items.length) + return true + } + if (event.key === 'ArrowDown') { + setSelectedIndex((i) => (i + 1) % items.length) + return true + } + if (event.key === 'Enter') { + selectItem(selectedIndex) + return true + } + return false + }, + })) + + if (items.length === 0) { + return ( +
+
검색 결과가 없습니다
+
+ ) + } + + return ( +
+ {items.map((item, i) => ( + + ))} +
+ ) +}) + +export default SlashCommandMenu diff --git a/src/components/post/editor/SlashCommandMenu.module.css b/src/components/post/editor/SlashCommandMenu.module.css new file mode 100644 index 0000000..4e41621 --- /dev/null +++ b/src/components/post/editor/SlashCommandMenu.module.css @@ -0,0 +1,94 @@ +.menu { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 220px; + max-width: 320px; + max-height: 320px; + overflow-y: auto; + padding: 0.375rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); +} + +.empty { + padding: 0.75rem 0.625rem; + font-size: 0.8125rem; + color: var(--color-text-muted); + text-align: center; +} + +.item, +.itemActive { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.4375rem 0.5rem; + border-radius: var(--radius-sm); + background: none; + border: none; + text-align: left; + width: 100%; +} + +.itemActive { + background: var(--color-surface-hover); +} + +.typeLabel { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); +} + +.thumb { + flex-shrink: 0; + background-color: var(--color-surface-hover); + background-size: cover; + background-position: center; + border: 1px solid var(--color-border); +} + +.thumbPortrait { + width: 24px; + height: 32px; + border-radius: var(--radius-xs); +} + +.thumbSquare { + width: 28px; + height: 28px; + border-radius: var(--radius-xs); +} + +.thumbCircle { + width: 26px; + height: 26px; + border-radius: 50%; +} + +.resultTxt { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + +.resultTitle { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.resultSub { + font-size: 0.71875rem; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/post/editor/mentionSearch.js b/src/components/post/editor/mentionSearch.js new file mode 100644 index 0000000..31cf290 --- /dev/null +++ b/src/components/post/editor/mentionSearch.js @@ -0,0 +1,52 @@ +export const MENTION_TYPE_OPTIONS = [ + { stage: 'type', type: 'CONCERT', label: '공연' }, + { stage: 'type', type: 'ARTIST', label: '아티스트' }, + { stage: 'type', type: 'RELEASE', label: '음악' }, +] + +const TYPE_LABEL_TO_TYPE = { 공연: 'CONCERT', 아티스트: 'ARTIST', 음악: 'RELEASE' } + +// '/공연 vaundy' 형태의 슬래시 커맨드 쿼리를 단계별로 해석한다 +// 1단계(type): 타입 선택 전 — 입력값으로 타입 목록을 좁혀 보여준다 +// 2단계(search): '{타입} ' 뒤로 검색어를 입력하면 해당 타입 내에서 검색한다 +export function parseSlashQuery(query) { + const spaceIdx = query.indexOf(' ') + if (spaceIdx === -1) return { stage: 'type', typeQuery: query } + + const typeToken = query.slice(0, spaceIdx) + const type = TYPE_LABEL_TO_TYPE[typeToken] + if (!type) return { stage: 'type', typeQuery: query } + + return { stage: 'search', type, searchTerm: query.slice(spaceIdx + 1) } +} + +export function filterTypeOptions(typeQuery) { + if (!typeQuery) return MENTION_TYPE_OPTIONS + return MENTION_TYPE_OPTIONS.filter((o) => o.label.includes(typeQuery)) +} + +// TODO: API 연동 후 제거 — GET /api/mentions/search 배포 완료 시 실 API 호출로 교체 +const MOCK_MENTION_MAP = { + CONCERT: [ + { id: 1, title: '요네즈 켄시 KOREA LIVE 2026', subtitle: '2026.11.14 · KSPO DOME', thumbnailUrl: null }, + { id: 4, title: '아이묭 서울 단독 공연', subtitle: '2026.10.02 · 예스24 라이브홀', thumbnailUrl: null }, + { id: 7, title: '요루시카 아시아 투어', subtitle: '2026.12.05 · 올림픽공원 체조경기장', thumbnailUrl: null }, + ], + ARTIST: [ + { id: 2, title: '요네즈 켄시', subtitle: null, thumbnailUrl: null }, + { id: 5, title: '아이묭', subtitle: null, thumbnailUrl: null }, + { id: 8, title: '요루시카', subtitle: null, thumbnailUrl: null }, + ], + RELEASE: [ + { id: 3, title: 'LOST CORNER', subtitle: '요네즈 켄시', thumbnailUrl: null }, + { id: 6, title: 'BOOM BOOM', subtitle: '아이묭', thumbnailUrl: null }, + { id: 9, title: '幻燈', subtitle: '요루시카', thumbnailUrl: null }, + ], +} + +export function searchMentions(type, term) { + const pool = MOCK_MENTION_MAP[type] ?? [] + const q = term.trim().toLowerCase() + const filtered = q ? pool.filter((item) => item.title.toLowerCase().includes(q)) : pool + return filtered.slice(0, 10).map((item) => ({ stage: 'search', type, ...item })) +} diff --git a/src/components/post/editor/slashCommand.jsx b/src/components/post/editor/slashCommand.jsx new file mode 100644 index 0000000..bca0fbb --- /dev/null +++ b/src/components/post/editor/slashCommand.jsx @@ -0,0 +1,89 @@ +import { Extension } from '@tiptap/core' +import Suggestion from '@tiptap/suggestion' +import { createRoot } from 'react-dom/client' + +import { filterTypeOptions, parseSlashQuery, searchMentions } from './mentionSearch' +import SlashCommandMenu from './SlashCommandMenu' + +function renderSlashMenu() { + let root + let container + let unmount + const menuRef = { current: null } + + return { + onStart: (props) => { + container = document.createElement('div') + root = createRoot(container) + root.render() + unmount = props.mount(container) + }, + onUpdate: (props) => { + root.render() + }, + onKeyDown: (props) => menuRef.current?.onKeyDown(props) ?? false, + onExit: () => { + unmount?.() + root?.unmount() + }, + } +} + +const SlashCommand = Extension.create({ + name: 'slashCommand', + + addOptions() { + return { + suggestion: { + char: '/', + allowSpaces: true, + startOfLine: false, + items: ({ query }) => { + const parsed = parseSlashQuery(query) + if (parsed.stage === 'type') return filterTypeOptions(parsed.typeQuery) + return searchMentions(parsed.type, parsed.searchTerm) + }, + render: renderSlashMenu, + command: ({ editor, range, props }) => { + if (props.stage === 'type') { + // range.from은 트리거 문자('/') 위치 — 그대로 두고 그 뒤의 쿼리만 교체해야 + // 슬래시 커맨드가 활성 상태를 유지한 채 2단계(검색)로 이어진다 + editor.chain().focus().insertContentAt({ from: range.from + 1, to: range.to }, `${props.label} `).run() + return + } + + const mentionNode = { + type: props.type === 'ARTIST' ? 'entityMentionChip' : 'entityMentionCard', + attrs: { + entityType: props.type, + entityId: props.id, + title: props.title, + subtitle: props.subtitle ?? null, + thumbnailUrl: props.thumbnailUrl ?? null, + }, + } + + // 블록 카드는 뒤에 빈 문단이 없으면 삽입 후 커서가 카드 자체를 선택한 + // 상태(NodeSelection)로 남아, 곧바로 타이핑하면 카드가 지워지고 텍스트로 + // 치환돼버린다 — 항상 빈 문단을 함께 넣어 텍스트 커서 위치를 보장한다 + const content = mentionNode.type === 'entityMentionCard' + ? [mentionNode, { type: 'paragraph' }] + : [mentionNode] + + editor.chain().focus().insertContentAt(range, content).run() + }, + }, + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) + +export default SlashCommand From 4dfdf1185ad762e92eaef546044dc6d5c8fb8dbe Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:25:43 +0900 Subject: [PATCH 06/50] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작성 시 저장된 Tiptap JSON을 읽기 전용 에디터로 재렌더링하는 PostContentView를 추가하고, 멘션 카드는 열람 모드에서 해당 엔티티 상세 페이지로 이동하는 링크가 되도록 한다. 상세 페이지에 제목·카테고리· 조회수·작성자·추천 버튼·본문을 mock 데이터로 완성하고, 작성자 본인일 때만 수정/삭제 버튼을 노출한다. Co-Authored-By: Claude Sonnet 5 --- src/components/post/PostContentView.jsx | 25 ++++ .../post/PostContentView.module.css | 17 +++ .../post/editor/EntityMentionCard.module.css | 3 +- .../post/editor/EntityMentionCardView.jsx | 35 ++++- .../post/editor/EntityMentionChipView.jsx | 32 ++++- src/pages/PostDetailPage.jsx | 123 +++++++++++++++++- src/pages/PostDetailPage.module.css | 108 +++++++++++++++ 7 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 src/components/post/PostContentView.jsx create mode 100644 src/components/post/PostContentView.module.css diff --git a/src/components/post/PostContentView.jsx b/src/components/post/PostContentView.jsx new file mode 100644 index 0000000..4ae6329 --- /dev/null +++ b/src/components/post/PostContentView.jsx @@ -0,0 +1,25 @@ +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' + +import { EntityMentionCard, EntityMentionChip } from './editor/entityMentionExtensions' +import styles from './PostContentView.module.css' + +// 작성 시 저장된 Tiptap JSON을 읽기 전용 에디터로 그대로 다시 렌더링한다 — +// 멘션 카드 NodeView를 작성/열람 양쪽에서 동일하게 재사용하기 위함 +function PostContentView({ content }) { + const editor = useEditor({ + extensions: [StarterKit, EntityMentionCard, EntityMentionChip], + content, + editable: false, + }) + + if (!editor) return null + + return ( +
+ +
+ ) +} + +export default PostContentView diff --git a/src/components/post/PostContentView.module.css b/src/components/post/PostContentView.module.css new file mode 100644 index 0000000..9dc8cc5 --- /dev/null +++ b/src/components/post/PostContentView.module.css @@ -0,0 +1,17 @@ +.prose { + font-size: 0.9375rem; + line-height: 1.8; + color: var(--color-text); +} + +.prose :global(.ProseMirror) { + outline: none; +} + +.prose :global(p) { + margin: 0 0 0.875rem; +} + +.prose :global(p:last-child) { + margin-bottom: 0; +} diff --git a/src/components/post/editor/EntityMentionCard.module.css b/src/components/post/editor/EntityMentionCard.module.css index efb059a..987e05c 100644 --- a/src/components/post/editor/EntityMentionCard.module.css +++ b/src/components/post/editor/EntityMentionCard.module.css @@ -6,7 +6,8 @@ background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); - cursor: default; + color: inherit; + text-decoration: none; transition: border-color 0.15s ease; } diff --git a/src/components/post/editor/EntityMentionCardView.jsx b/src/components/post/editor/EntityMentionCardView.jsx index 7094483..99d497a 100644 --- a/src/components/post/editor/EntityMentionCardView.jsx +++ b/src/components/post/editor/EntityMentionCardView.jsx @@ -1,14 +1,13 @@ +import { Link } from 'react-router-dom' import { NodeViewWrapper } from '@tiptap/react' import { MENTION_TYPE_LABEL } from '@/constants/post' +import { ROUTES } from '@/constants/routes' import styles from './EntityMentionCard.module.css' -function EntityMentionCardView({ node }) { - const { entityType, title, subtitle, thumbnailUrl } = node.attrs - const variantClass = entityType === 'CONCERT' ? styles.concert : styles.release - +function CardInner({ entityType, title, subtitle, thumbnailUrl }) { return ( - + <>
+ + ) +} + +function getDetailPath(entityType, entityId) { + return entityType === 'CONCERT' ? ROUTES.CONCERT_DETAIL(entityId) : ROUTES.RELEASE_DETAIL(entityId) +} + +function EntityMentionCardView({ node, editor }) { + const { entityType, entityId, title, subtitle, thumbnailUrl } = node.attrs + const variantClass = entityType === 'CONCERT' ? styles.concert : styles.release + const className = `${styles.card} ${variantClass}` + + if (!editor.isEditable) { + return ( + + + + + + ) + } + + return ( + + ) } diff --git a/src/components/post/editor/EntityMentionChipView.jsx b/src/components/post/editor/EntityMentionChipView.jsx index c08403c..bd68692 100644 --- a/src/components/post/editor/EntityMentionChipView.jsx +++ b/src/components/post/editor/EntityMentionChipView.jsx @@ -1,16 +1,34 @@ +import { Link } from 'react-router-dom' import { NodeViewWrapper } from '@tiptap/react' +import { ROUTES } from '@/constants/routes' import styles from './EntityMentionCard.module.css' -function EntityMentionChipView({ node }) { - const { title, thumbnailUrl } = node.attrs +function EntityMentionChipView({ node, editor }) { + const { entityId, title, thumbnailUrl } = node.attrs + const className = `${styles.card} ${styles.artist}` + + const thumb = ( + + ) + + if (!editor.isEditable) { + return ( + + + {thumb} + {title} + + + ) + } return ( - - + + {thumb} {title} ) diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index 7384fc9..62b79a6 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -1,24 +1,145 @@ +import { useState } from 'react' import { useParams } from 'react-router-dom' import BackButton from '@/components/ui/BackButton' +import PostContentView from '@/components/post/PostContentView' import usePageMeta from '@/hooks/usePageMeta' import { ROUTES } from '@/constants/routes' +import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' +import { formatDateTime } from '@/utils/date' import styles from './PostDetailPage.module.css' +// TODO: API 연동 후 제거 — GET /api/posts/{id} 배포 완료 시 실 데이터로 교체 +const MOCK_POST_DETAIL_MAP = { + 10: { + category: 'REVIEW', + title: '요네즈 켄시 내한 첫 공연, 앙코르만 3번', + authorNickname: 'moonlit_haze', + isAuthor: true, + recommendCount: 128, + viewCount: 3204, + createdAt: '2026-09-10T09:14:00', + content: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: '드디어 요네즈 켄시 첫 내한을 다녀왔습니다. 오프닝부터 KICK BACK으로 시작해서 세트리스트 내내 텐션이 떨어지질 않았어요.' }] }, + { type: 'entityMentionCard', attrs: { entityType: 'CONCERT', entityId: 1, title: '요네즈 켄시 KOREA LIVE 2026', subtitle: '2026.11.14 · KSPO DOME', thumbnailUrl: null } }, + { + type: 'paragraph', + content: [ + { type: 'text', text: '특히 ' }, + { type: 'entityMentionChip', attrs: { entityType: 'ARTIST', entityId: 2, title: '요네즈 켄시', thumbnailUrl: null } }, + { type: 'text', text: '가 최근 발매한 정규 음반 수록곡 구간에서 분위기가 확 바뀌는데, 라이브 편곡이 원곡보다 더 좋았다는 의견이 많더라고요.' }, + ], + }, + { type: 'entityMentionCard', attrs: { entityType: 'RELEASE', entityId: 3, title: 'LOST CORNER', subtitle: '요네즈 켄시', thumbnailUrl: null } }, + { type: 'paragraph', content: [{ type: 'text', text: '앙코르만 세 번 나온 건 이번 투어 통틀어 처음이라고 하네요.' }] }, + ], + }, + }, + 9: { + category: 'INFO', + title: 'KSPO DOME 좌석 시야 후기 모아봤어요 (2·3층 위주)', + authorNickname: 'tteok_and_roll', + isAuthor: false, + recommendCount: 41, + viewCount: 1850, + createdAt: '2026-09-10T02:40:00', + content: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: '2층 사이드는 스탠딩 뒤통수 없이 편하게 볼 수 있었고, 3층은 무대 전체가 한눈에 들어와서 연출 보기엔 오히려 좋았어요.' }] }, + { type: 'entityMentionCard', attrs: { entityType: 'CONCERT', entityId: 1, title: '요네즈 켄시 KOREA LIVE 2026', subtitle: '2026.11.14 · KSPO DOME', thumbnailUrl: null } }, + ], + }, + }, +} + +function getMockPostDetail(postId) { + const detail = MOCK_POST_DETAIL_MAP[postId] + if (detail) return detail + + return { + category: 'FREE', + title: `게시글 #${postId}`, + authorNickname: `user_${postId}`, + isAuthor: false, + recommendCount: 0, + viewCount: 0, + createdAt: '2026-01-01T00:00:00', + content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '내용이 없습니다.' }] }] }, + } +} + function PostDetailPage() { const { id } = useParams() const postId = Number(id) + const post = getMockPostDetail(postId) + const [isRecommended, setIsRecommended] = useState(false) + const [recommendCount, setRecommendCount] = useState(post.recommendCount) usePageMeta({ - title: '커뮤니티 - 커밍', + title: `${post.title} - 커밍`, description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', path: `/community/${postId}`, }) + function handleRecommendToggle() { + // TODO: API 연동 후 제거 — POST/DELETE /api/posts/{id}/recommend 배포 완료 시 실 연동 + setIsRecommended((prev) => !prev) + setRecommendCount((prev) => prev + (isRecommended ? -1 : 1)) + } + + function handleEdit() { + // TODO: 수정 시 기존 내용 프리필 — 작성 폼에 편집 모드 추가 후 연결 + } + + function handleDelete() { + // TODO: API 연동 후 제거 — DELETE /api/posts/{id} 배포 완료 시 실 연동 + } + return (
+ +
+ + {POST_CATEGORY_LABEL[post.category]} + + {formatDateTime(post.createdAt)} + + 조회 {post.viewCount.toLocaleString()} +
+ +

{post.title}

+ +
+ {post.authorNickname ?? '탈퇴 회원'} + {post.isAuthor && ( +
+ + +
+ )} +
+ + + +
+ +
+
) diff --git a/src/pages/PostDetailPage.module.css b/src/pages/PostDetailPage.module.css index 59122a7..1749357 100644 --- a/src/pages/PostDetailPage.module.css +++ b/src/pages/PostDetailPage.module.css @@ -7,3 +7,111 @@ max-width: 760px; margin: 0 auto; } + +.meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1.25rem 0 0.75rem; +} + +.badge { + padding: 0.2em 0.55em; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--color-on-accent); + border-radius: var(--radius-xs); + line-height: 1.4; +} + +.metaText { + font-size: 0.8125rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.dot { + color: var(--color-text-muted); + opacity: 0.6; +} + +.title { + margin: 0 0 1rem; + font-size: 1.625rem; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.01em; + line-height: 1.35; +} + +.authorRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.author { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); +} + +.authorActions { + display: flex; + gap: 0.5rem; +} + +.authorActionBtn { + padding: 0.3rem 0.7rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: 0.8125rem; + transition: border-color 0.15s, color 0.15s; +} + +.authorActionBtn:hover { + border-color: var(--color-text-muted); + color: var(--color-text); +} + +.footer { + display: flex; + justify-content: center; + margin-top: 2.5rem; + padding-top: 2rem; + border-top: 1px solid var(--color-border); +} + +.recommendBtn, +.recommendBtnActive { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.5rem; + border-radius: var(--radius-full); + border: 1.5px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: 0.9375rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + transition: border-color 0.15s, color 0.15s, background-color 0.15s; +} + +.recommendBtn:hover { + border-color: var(--color-accent-border); + color: var(--color-accent); +} + +.recommendBtnActive { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-on-accent); +} From 03f122c00d7cacdb131954a11c1d1e5ae6c4ba34 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:27:37 +0900 Subject: [PATCH 07/50] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=8F=BC=20=EC=99=84=EC=84=B1=20(?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=C2=B7=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=C2=B7entityTags=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제목 입력, 카테고리(후기/정보·제보/자유) 선택, 본문 멘션에서 자동 추출한 entityTags 미리보기를 추가한다. 후기·정보 카테고리는 entityTags 1개 이상을 요구하도록 검증하고, 제출은 mock으로 처리한다. Co-Authored-By: Claude Sonnet 5 --- .../post/editor/extractEntityTags.js | 24 ++++ src/pages/PostWritePage.jsx | 88 ++++++++++++- src/pages/PostWritePage.module.css | 121 ++++++++++++++++++ 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/components/post/editor/extractEntityTags.js diff --git a/src/components/post/editor/extractEntityTags.js b/src/components/post/editor/extractEntityTags.js new file mode 100644 index 0000000..9f84d73 --- /dev/null +++ b/src/components/post/editor/extractEntityTags.js @@ -0,0 +1,24 @@ +// Tiptap JSON 트리를 순회해 본문에 삽입된 entityMentionCard·entityMentionChip +// 노드들을 entityTags 형태({entityType, entityId})로 추출한다 (중복 제거) +export function extractEntityTags(contentJson) { + const tags = [] + const seen = new Set() + + function walk(node) { + if (!node) return + + if (node.type === 'entityMentionCard' || node.type === 'entityMentionChip') { + const { entityType, entityId, title } = node.attrs ?? {} + const key = `${entityType}:${entityId}` + if (entityType && entityId != null && !seen.has(key)) { + seen.add(key) + tags.push({ entityType, entityId, title }) + } + } + + node.content?.forEach(walk) + } + + walk(contentJson) + return tags +} diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx index 838ac5c..0cf52a0 100644 --- a/src/pages/PostWritePage.jsx +++ b/src/pages/PostWritePage.jsx @@ -1,11 +1,24 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' import PostEditor from '@/components/post/editor/PostEditor' +import { extractEntityTags } from '@/components/post/editor/extractEntityTags' import usePageMeta from '@/hooks/usePageMeta' +import { ROUTES } from '@/constants/routes' +import { CATEGORIES_REQUIRING_MENTION, MENTION_TYPE_LABEL, POST_CATEGORY_LABEL } from '@/constants/post' import styles from './PostWritePage.module.css' +const CATEGORIES = ['REVIEW', 'INFO', 'FREE'] + function PostWritePage() { + const navigate = useNavigate() + const [title, setTitle] = useState('') + const [category, setCategory] = useState('REVIEW') const [contentJson, setContentJson] = useState(null) + const [error, setError] = useState(null) + + const entityTags = useMemo(() => extractEntityTags(contentJson), [contentJson]) + const mentionRequired = CATEGORIES_REQUIRING_MENTION.includes(category) usePageMeta({ title: '글쓰기 - 커밍', @@ -13,13 +26,86 @@ function PostWritePage() { path: '/community/write', }) + function handleSubmit() { + if (!title.trim()) { + setError('제목을 입력해주세요.') + return + } + if (mentionRequired && entityTags.length === 0) { + setError(`${POST_CATEGORY_LABEL[category]} 게시글은 공연·아티스트·음악을 1개 이상 태그해야 해요. 본문에 '/'를 입력해 태그해보세요.`) + return + } + setError(null) + + // TODO: API 연동 후 제거 — POST /api/posts 배포 완료 시 실 연동 + const payload = { + category, + title: title.trim(), + content: contentJson, + entityTags: entityTags.map(({ entityType, entityId }) => ({ entityType, entityId })), + } + console.info('[mock] POST /api/posts', payload) + navigate(ROUTES.COMMUNITY) + } + return (

글쓰기

+ +
+ {CATEGORIES.map((c) => ( + + ))} +
+ + setTitle(e.target.value)} + maxLength={100} + /> + + +
+ + 태그된 항목{mentionRequired ? ' (1개 이상 필수)' : ''} + + {entityTags.length > 0 ? ( +
+ {entityTags.map((tag) => ( + + {MENTION_TYPE_LABEL[tag.entityType]} · {tag.title} + + ))} +
+ ) : ( + 본문에 '/'를 입력해 공연·아티스트·음악을 태그해보세요 + )} +
+ + {error &&

{error}

} + +
+ +
+
) diff --git a/src/pages/PostWritePage.module.css b/src/pages/PostWritePage.module.css index 93675db..84a4cf4 100644 --- a/src/pages/PostWritePage.module.css +++ b/src/pages/PostWritePage.module.css @@ -19,3 +19,124 @@ color: var(--color-text); letter-spacing: -0.02em; } + +/* ── 카테고리 선택 ── */ +.categoryBar { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.categoryTab, +.categoryTabActive { + padding: 0.375rem 1rem; + border-radius: var(--radius-full); + font-size: 0.875rem; + font-weight: 500; + border: 1.5px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + transition: border-color 0.15s, color 0.15s, background-color 0.15s; +} + +.categoryTab:hover { + border-color: var(--color-accent-border); + color: var(--color-accent); +} + +.categoryTabActive { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-on-accent); + font-weight: 600; +} + +/* ── 제목 입력 ── */ +.titleInput { + display: block; + width: 100%; + padding: 0.75rem 0.875rem; + margin-bottom: 0.875rem; + border-radius: var(--radius-lg); + border: 1.5px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font-size: 1.0625rem; + font-weight: 600; + font-family: var(--font-sans); + outline: none; + transition: border-color 0.15s; +} + +.titleInput:focus { + border-color: var(--color-accent); +} + +.titleInput::placeholder { + color: var(--color-text-muted); + font-weight: 500; +} + +/* ── 태그 미리보기 ── */ +.tagRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.875rem; +} + +.tagRowLabel { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.tagRowEmpty { + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.tagChip { + padding: 0.25rem 0.625rem; + border-radius: var(--radius-full); + background: var(--color-accent-dim); + color: var(--color-accent); + font-size: 0.75rem; + font-weight: 600; +} + +/* ── 에러 ── */ +.error { + margin: 0.875rem 0 0; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-destructive); +} + +/* ── 제출 ── */ +.submitRow { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; +} + +.submitBtn { + padding: 0.625rem 1.75rem; + border-radius: var(--radius-lg); + background: var(--color-accent); + color: var(--color-on-accent); + font-size: 0.9375rem; + font-weight: 600; + transition: background-color 0.15s; +} + +.submitBtn:hover { + background: var(--color-accent-dark); +} From 36b58915003d1bcddea4ab5546b138acfe6042fc Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:28:30 +0900 Subject: [PATCH 08/50] =?UTF-8?q?[feat]=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=83=81=ED=83=9C=EC=9D=98=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=AA=A8=EB=8B=AC=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추천 버튼 클릭 시 비로그인 상태면 상태를 바꾸지 않고 로그인 모달을 띄우도록 하고, 작성자 전용 수정/삭제 버튼도 로그인 여부와 함께 판단하도록 한다(비인증 시 isAuthor는 항상 false라는 스펙에 맞춤). Co-Authored-By: Claude Sonnet 5 --- src/pages/PostDetailPage.jsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index 62b79a6..e21df36 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -7,6 +7,8 @@ import usePageMeta from '@/hooks/usePageMeta' import { ROUTES } from '@/constants/routes' import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' import { formatDateTime } from '@/utils/date' +import useAuthStore from '@/stores/authStore' +import useLoginModalStore from '@/stores/loginModalStore' import styles from './PostDetailPage.module.css' // TODO: API 연동 후 제거 — GET /api/posts/{id} 배포 완료 시 실 데이터로 교체 @@ -77,6 +79,9 @@ function PostDetailPage() { const post = getMockPostDetail(postId) const [isRecommended, setIsRecommended] = useState(false) const [recommendCount, setRecommendCount] = useState(post.recommendCount) + const user = useAuthStore((s) => s.user) + const openLoginModal = useLoginModalStore((s) => s.open) + const isAuthor = user && post.isAuthor usePageMeta({ title: `${post.title} - 커밍`, @@ -85,6 +90,10 @@ function PostDetailPage() { }) function handleRecommendToggle() { + if (!user) { + openLoginModal(window.location.pathname + window.location.search) + return + } // TODO: API 연동 후 제거 — POST/DELETE /api/posts/{id}/recommend 배포 완료 시 실 연동 setIsRecommended((prev) => !prev) setRecommendCount((prev) => prev + (isRecommended ? -1 : 1)) @@ -116,7 +125,7 @@ function PostDetailPage() {
{post.authorNickname ?? '탈퇴 회원'} - {post.isAuthor && ( + {isAuthor && (
From 6b0d3e0abe27ad20c0355da3c2f32520bdbad5ea Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:45:19 +0900 Subject: [PATCH 09/50] =?UTF-8?q?[feat]=20=EB=AA=A9=EB=A1=9D/=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=8B=A4=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit postApi.js(getPosts, getPost)를 추가하고 목록·상세 페이지의 mock 데이터를 GET /api/posts, GET /api/posts/{id} 실 연동으로 교체한다. 로딩 스켈레톤과 상세 페이지 404 EmptyState를 추가하고, isAuthor· isRecommended는 서버가 인증 상태 기준으로 계산한 값을 그대로 사용한다. 목록 행 전체가 링크라 텍스트에 밑줄이 상속되던 스타일 버그도 함께 수정. Co-Authored-By: Claude Sonnet 5 --- src/components/post/PostListItem.module.css | 1 + src/components/post/PostListItemSkeleton.jsx | 15 +++ .../post/PostListItemSkeleton.module.css | 64 +++++++++++ src/pages/PostDetailPage.jsx | 108 +++++++----------- src/pages/PostDetailPage.module.css | 36 ++++++ src/pages/PostsPage.jsx | 58 ++++------ src/services/postApi.js | 11 ++ 7 files changed, 193 insertions(+), 100 deletions(-) create mode 100644 src/components/post/PostListItemSkeleton.jsx create mode 100644 src/components/post/PostListItemSkeleton.module.css create mode 100644 src/services/postApi.js diff --git a/src/components/post/PostListItem.module.css b/src/components/post/PostListItem.module.css index 23dc427..71757c7 100644 --- a/src/components/post/PostListItem.module.css +++ b/src/components/post/PostListItem.module.css @@ -6,6 +6,7 @@ padding: 1rem 0.25rem; border-bottom: 1px solid var(--color-border); color: inherit; + text-decoration: none; transition: background-color 0.15s; } diff --git a/src/components/post/PostListItemSkeleton.jsx b/src/components/post/PostListItemSkeleton.jsx new file mode 100644 index 0000000..7693355 --- /dev/null +++ b/src/components/post/PostListItemSkeleton.jsx @@ -0,0 +1,15 @@ +import styles from './PostListItemSkeleton.module.css' + +function PostListItemSkeleton() { + return ( +
+
+
+
+
+
+
+ ) +} + +export default PostListItemSkeleton diff --git a/src/components/post/PostListItemSkeleton.module.css b/src/components/post/PostListItemSkeleton.module.css new file mode 100644 index 0000000..1c86ecb --- /dev/null +++ b/src/components/post/PostListItemSkeleton.module.css @@ -0,0 +1,64 @@ +@keyframes shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0.25rem; + border-bottom: 1px solid var(--color-border); +} + +.main { + display: flex; + align-items: center; + gap: 0.625rem; + flex: 1; + min-width: 0; +} + +.badge, +.title, +.meta { + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + var(--color-surface) 25%, + var(--color-surface-hover) 50%, + var(--color-surface) 75% + ); + background-size: 800px 100%; + animation: shimmer 1.4s infinite linear; +} + +.badge { + width: 40px; + height: 18px; + flex-shrink: 0; +} + +.title { + width: 55%; + height: 15px; +} + +.meta { + width: 140px; + height: 13px; + flex-shrink: 0; +} + +@media (max-width: 767px) { + .row { + flex-direction: column; + align-items: flex-start; + gap: 0.625rem; + } + + .meta { + align-self: flex-start; + } +} diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index e21df36..d44294d 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -1,9 +1,12 @@ import { useState } from 'react' import { useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' import BackButton from '@/components/ui/BackButton' +import EmptyState from '@/components/ui/EmptyState' import PostContentView from '@/components/post/PostContentView' import usePageMeta from '@/hooks/usePageMeta' +import { getPost } from '@/services/postApi' import { ROUTES } from '@/constants/routes' import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' import { formatDateTime } from '@/utils/date' @@ -11,84 +14,55 @@ import useAuthStore from '@/stores/authStore' import useLoginModalStore from '@/stores/loginModalStore' import styles from './PostDetailPage.module.css' -// TODO: API 연동 후 제거 — GET /api/posts/{id} 배포 완료 시 실 데이터로 교체 -const MOCK_POST_DETAIL_MAP = { - 10: { - category: 'REVIEW', - title: '요네즈 켄시 내한 첫 공연, 앙코르만 3번', - authorNickname: 'moonlit_haze', - isAuthor: true, - recommendCount: 128, - viewCount: 3204, - createdAt: '2026-09-10T09:14:00', - content: { - type: 'doc', - content: [ - { type: 'paragraph', content: [{ type: 'text', text: '드디어 요네즈 켄시 첫 내한을 다녀왔습니다. 오프닝부터 KICK BACK으로 시작해서 세트리스트 내내 텐션이 떨어지질 않았어요.' }] }, - { type: 'entityMentionCard', attrs: { entityType: 'CONCERT', entityId: 1, title: '요네즈 켄시 KOREA LIVE 2026', subtitle: '2026.11.14 · KSPO DOME', thumbnailUrl: null } }, - { - type: 'paragraph', - content: [ - { type: 'text', text: '특히 ' }, - { type: 'entityMentionChip', attrs: { entityType: 'ARTIST', entityId: 2, title: '요네즈 켄시', thumbnailUrl: null } }, - { type: 'text', text: '가 최근 발매한 정규 음반 수록곡 구간에서 분위기가 확 바뀌는데, 라이브 편곡이 원곡보다 더 좋았다는 의견이 많더라고요.' }, - ], - }, - { type: 'entityMentionCard', attrs: { entityType: 'RELEASE', entityId: 3, title: 'LOST CORNER', subtitle: '요네즈 켄시', thumbnailUrl: null } }, - { type: 'paragraph', content: [{ type: 'text', text: '앙코르만 세 번 나온 건 이번 투어 통틀어 처음이라고 하네요.' }] }, - ], - }, - }, - 9: { - category: 'INFO', - title: 'KSPO DOME 좌석 시야 후기 모아봤어요 (2·3층 위주)', - authorNickname: 'tteok_and_roll', - isAuthor: false, - recommendCount: 41, - viewCount: 1850, - createdAt: '2026-09-10T02:40:00', - content: { - type: 'doc', - content: [ - { type: 'paragraph', content: [{ type: 'text', text: '2층 사이드는 스탠딩 뒤통수 없이 편하게 볼 수 있었고, 3층은 무대 전체가 한눈에 들어와서 연출 보기엔 오히려 좋았어요.' }] }, - { type: 'entityMentionCard', attrs: { entityType: 'CONCERT', entityId: 1, title: '요네즈 켄시 KOREA LIVE 2026', subtitle: '2026.11.14 · KSPO DOME', thumbnailUrl: null } }, - ], - }, - }, -} - -function getMockPostDetail(postId) { - const detail = MOCK_POST_DETAIL_MAP[postId] - if (detail) return detail - - return { - category: 'FREE', - title: `게시글 #${postId}`, - authorNickname: `user_${postId}`, - isAuthor: false, - recommendCount: 0, - viewCount: 0, - createdAt: '2026-01-01T00:00:00', - content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '내용이 없습니다.' }] }] }, - } -} - function PostDetailPage() { const { id } = useParams() const postId = Number(id) - const post = getMockPostDetail(postId) const [isRecommended, setIsRecommended] = useState(false) - const [recommendCount, setRecommendCount] = useState(post.recommendCount) + const [recommendCount, setRecommendCount] = useState(0) + const [syncedPost, setSyncedPost] = useState(null) const user = useAuthStore((s) => s.user) const openLoginModal = useLoginModalStore((s) => s.open) - const isAuthor = user && post.isAuthor + + const { data: post, isLoading, isError } = useQuery({ + queryKey: ['post', postId], + queryFn: () => getPost(postId), + retry: false, + }) + + if (post && post !== syncedPost) { + setSyncedPost(post) + setIsRecommended(!!post.isRecommended) + setRecommendCount(post.recommendCount) + } usePageMeta({ - title: `${post.title} - 커밍`, + title: post ? `${post.title} - 커밍` : undefined, description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', path: `/community/${postId}`, }) + if (isLoading) { + return ( +
+
+ +
+
+
+
+
+ ) + } + + if (isError || !post) { + return ( + + ) + } + function handleRecommendToggle() { if (!user) { openLoginModal(window.location.pathname + window.location.search) @@ -125,7 +99,7 @@ function PostDetailPage() {
{post.authorNickname ?? '탈퇴 회원'} - {isAuthor && ( + {post.isAuthor && (
diff --git a/src/pages/PostDetailPage.module.css b/src/pages/PostDetailPage.module.css index 1749357..9b87694 100644 --- a/src/pages/PostDetailPage.module.css +++ b/src/pages/PostDetailPage.module.css @@ -8,6 +8,42 @@ margin: 0 auto; } +@keyframes shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} + +.skeletonMeta, +.skeletonTitle, +.skeletonBody { + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + var(--color-surface) 25%, + var(--color-surface-hover) 50%, + var(--color-surface) 75% + ); + background-size: 800px 100%; + animation: shimmer 1.4s infinite linear; +} + +.skeletonMeta { + width: 160px; + height: 20px; + margin: 1.25rem 0 1rem; +} + +.skeletonTitle { + width: 70%; + height: 32px; + margin-bottom: 1.5rem; +} + +.skeletonBody { + width: 100%; + height: 240px; +} + .meta { display: flex; align-items: center; diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index c31dd9f..93e4c76 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -1,9 +1,12 @@ import { useNavigate, useSearchParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' import EmptyState from '@/components/ui/EmptyState' import Pagination from '@/components/ui/Pagination' import PostListItem from '@/components/post/PostListItem' +import PostListItemSkeleton from '@/components/post/PostListItemSkeleton' import usePageMeta from '@/hooks/usePageMeta' +import { getPosts } from '@/services/postApi' import { ROUTES } from '@/constants/routes' import { POST_CATEGORY_LABEL } from '@/constants/post' import useAuthStore from '@/stores/authStore' @@ -11,30 +14,7 @@ import useLoginModalStore from '@/stores/loginModalStore' import styles from './PostsPage.module.css' const CATEGORY_FILTERS = ['ALL', 'REVIEW', 'INFO', 'FREE'] -const ITEMS_PER_PAGE = 10 - -// TODO: API 연동 후 제거 — GET /api/posts 배포 완료 시 실 데이터로 교체 -const MOCK_POST_LIST = [ - { id: 10, category: 'REVIEW', title: '요네즈 켄시 내한 첫 공연, 앙코르만 3번', authorNickname: 'moonlit_haze', recommendCount: 128, viewCount: 3204, createdAt: '2026-09-10T09:14:00' }, - { id: 9, category: 'INFO', title: 'KSPO DOME 좌석 시야 후기 모아봤어요 (2·3층 위주)', authorNickname: 'tteok_and_roll', recommendCount: 41, viewCount: 1850, createdAt: '2026-09-10T02:40:00' }, - { id: 8, category: 'FREE', title: '이번 투어 응모 결과 다들 어떠셨나요', authorNickname: 'nagoya_express', recommendCount: 9, viewCount: 640, createdAt: '2026-09-09T21:05:00' }, - { id: 7, category: 'REVIEW', title: 'LOST CORNER 발매 기념 감상평 남깁니다', authorNickname: 'hanare_bito', recommendCount: 63, viewCount: 2410, createdAt: '2026-09-06T18:30:00' }, - { id: 6, category: 'INFO', title: '공연장 근처 굿즈 부스 운영시간 정리', authorNickname: 'yoruno_neko', recommendCount: 22, viewCount: 1120, createdAt: '2026-09-04T11:00:00' }, - { id: 5, category: 'FREE', title: '내한 소식 듣고 바로 연차 냈습니다', authorNickname: 'string_hoshi', recommendCount: 17, viewCount: 980, createdAt: '2026-08-22T08:12:00' }, - { id: 4, category: 'REVIEW', title: '2025년 마지막 공연 회고', authorNickname: 'moonlit_haze', recommendCount: 54, viewCount: 1760, createdAt: '2025-12-30T20:15:00' }, - { id: 3, category: 'INFO', title: '작년 굿즈 라인업 아카이브 (사진 有)', authorNickname: 'tteok_and_roll', recommendCount: 30, viewCount: 2050, createdAt: '2025-11-02T13:45:00' }, - { id: 2, category: 'FREE', title: '입덕 계기가 뭐였나요', authorNickname: 'hanare_bito', recommendCount: 12, viewCount: 730, createdAt: '2025-09-18T09:00:00' }, - { id: 1, category: 'REVIEW', title: 'KICK BACK 오프닝 직캠 후기', authorNickname: null, recommendCount: 5, viewCount: 410, createdAt: '2025-08-15T19:20:00' }, - ...Array.from({ length: 14 }, (_, i) => ({ - id: -1 - i, - category: CATEGORY_FILTERS[1 + (i % 3)], - title: `아카이브 게시글 ${i + 1}`, - authorNickname: `user_${i + 1}`, - recommendCount: (i * 7) % 40, - viewCount: 100 + i * 63, - createdAt: `2025-0${1 + (i % 6)}-1${i % 9}T10:00:00`, - })), -] +const ITEMS_PER_PAGE = 20 function PostsPage() { const [searchParams, setSearchParams] = useSearchParams() @@ -53,6 +33,19 @@ function PostsPage() { path: '/community', }) + const { data, isLoading } = useQuery({ + queryKey: ['posts', selectedCategory, currentPage], + queryFn: () => getPosts({ + category: selectedCategory === 'ALL' ? undefined : selectedCategory, + page: currentPage - 1, + size: ITEMS_PER_PAGE, + }), + placeholderData: (prev) => prev, + }) + + const posts = data?.content ?? [] + const totalPages = data?.totalPages ?? 1 + function handleCategoryChange(category) { setSearchParams((prev) => { const next = new URLSearchParams(prev) @@ -82,13 +75,6 @@ function PostsPage() { navigate(ROUTES.COMMUNITY_WRITE) } - const filtered = selectedCategory === 'ALL' - ? MOCK_POST_LIST - : MOCK_POST_LIST.filter((p) => p.category === selectedCategory) - const totalElements = filtered.length - const totalPages = Math.max(1, Math.ceil(totalElements / ITEMS_PER_PAGE)) - const posts = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE) - return (
@@ -114,7 +100,13 @@ function PostsPage() { ))}
- {posts.length > 0 ? ( + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : posts.length > 0 ? (
{posts.map((post) => ( @@ -124,7 +116,7 @@ function PostsPage() { )} - {totalPages > 1 && ( + {!isLoading && totalPages > 1 && ( Date: Fri, 11 Sep 2026 00:29:40 +0900 Subject: [PATCH 10/50] =?UTF-8?q?[feat]=20#157=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=B6=94=EC=B2=9C/=EC=B6=94=EC=B2=9C=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20API=20=EC=8B=A4=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BE에 추천 관련 엔드포인트(POST/DELETE /api/posts/{id}/recommend)가 구현되어 mock 처리를 실 API 호출로 교체. 클릭 즉시 UI를 낙관적으로 갱신하고, 실패 시 이전 상태로 롤백한다. Co-Authored-By: Claude Sonnet 5 --- src/pages/PostDetailPage.jsx | 19 ++++++++++++++----- src/services/postApi.js | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index d44294d..c8a97dc 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -1,12 +1,12 @@ import { useState } from 'react' import { useParams } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation } from '@tanstack/react-query' import BackButton from '@/components/ui/BackButton' import EmptyState from '@/components/ui/EmptyState' import PostContentView from '@/components/post/PostContentView' import usePageMeta from '@/hooks/usePageMeta' -import { getPost } from '@/services/postApi' +import { getPost, recommendPost, unrecommendPost } from '@/services/postApi' import { ROUTES } from '@/constants/routes' import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' import { formatDateTime } from '@/utils/date' @@ -35,6 +35,14 @@ function PostDetailPage() { setRecommendCount(post.recommendCount) } + const recommendMutation = useMutation({ + mutationFn: (next) => (next ? recommendPost(postId) : unrecommendPost(postId)), + onError: (_err, next) => { + setIsRecommended(!next) + setRecommendCount((prev) => prev + (next ? -1 : 1)) + }, + }) + usePageMeta({ title: post ? `${post.title} - 커밍` : undefined, description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', @@ -68,9 +76,10 @@ function PostDetailPage() { openLoginModal(window.location.pathname + window.location.search) return } - // TODO: API 연동 후 제거 — POST/DELETE /api/posts/{id}/recommend 배포 완료 시 실 연동 - setIsRecommended((prev) => !prev) - setRecommendCount((prev) => prev + (isRecommended ? -1 : 1)) + const next = !isRecommended + setIsRecommended(next) + setRecommendCount((prev) => prev + (next ? 1 : -1)) + recommendMutation.mutate(next) } function handleEdit() { diff --git a/src/services/postApi.js b/src/services/postApi.js index dc8f95c..5a5ca07 100644 --- a/src/services/postApi.js +++ b/src/services/postApi.js @@ -9,3 +9,18 @@ export async function getPost(id) { const { data } = await api.get(`/posts/${id}`) return data } + +export async function searchMentions(params) { + const { data } = await api.get('/mentions/search', { params }) + return data +} + +export async function recommendPost(id) { + const { data } = await api.post(`/posts/${id}/recommend`) + return data +} + +export async function unrecommendPost(id) { + const { data } = await api.delete(`/posts/${id}/recommend`) + return data +} From aedc1373cfb0b054e61bd3175725dbabef6cb944 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 00:34:13 +0900 Subject: [PATCH 11/50] =?UTF-8?q?[feat]=20#157=20=EB=A9=98=EC=85=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89(/)=20API=20=EC=8B=A4=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/mentions/search 배포 완료에 따라 슬래시 커맨드 검색 단계의 mock 데이터를 제거하고 실 API 비동기 호출로 교체. 불필요한 요청을 줄이기 위해 200ms debounce를 적용하고, 검색어가 비어있을 때는 API를 호출하지 않고 빈 결과를 반환한다. Co-Authored-By: Claude Sonnet 5 --- src/components/post/editor/mentionSearch.js | 31 +++++---------------- src/components/post/editor/slashCommand.jsx | 1 + 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/components/post/editor/mentionSearch.js b/src/components/post/editor/mentionSearch.js index 31cf290..3de2a70 100644 --- a/src/components/post/editor/mentionSearch.js +++ b/src/components/post/editor/mentionSearch.js @@ -1,3 +1,5 @@ +import { searchMentions as apiSearchMentions } from '@/services/postApi' + export const MENTION_TYPE_OPTIONS = [ { stage: 'type', type: 'CONCERT', label: '공연' }, { stage: 'type', type: 'ARTIST', label: '아티스트' }, @@ -25,28 +27,9 @@ export function filterTypeOptions(typeQuery) { return MENTION_TYPE_OPTIONS.filter((o) => o.label.includes(typeQuery)) } -// TODO: API 연동 후 제거 — GET /api/mentions/search 배포 완료 시 실 API 호출로 교체 -const MOCK_MENTION_MAP = { - CONCERT: [ - { id: 1, title: '요네즈 켄시 KOREA LIVE 2026', subtitle: '2026.11.14 · KSPO DOME', thumbnailUrl: null }, - { id: 4, title: '아이묭 서울 단독 공연', subtitle: '2026.10.02 · 예스24 라이브홀', thumbnailUrl: null }, - { id: 7, title: '요루시카 아시아 투어', subtitle: '2026.12.05 · 올림픽공원 체조경기장', thumbnailUrl: null }, - ], - ARTIST: [ - { id: 2, title: '요네즈 켄시', subtitle: null, thumbnailUrl: null }, - { id: 5, title: '아이묭', subtitle: null, thumbnailUrl: null }, - { id: 8, title: '요루시카', subtitle: null, thumbnailUrl: null }, - ], - RELEASE: [ - { id: 3, title: 'LOST CORNER', subtitle: '요네즈 켄시', thumbnailUrl: null }, - { id: 6, title: 'BOOM BOOM', subtitle: '아이묭', thumbnailUrl: null }, - { id: 9, title: '幻燈', subtitle: '요루시카', thumbnailUrl: null }, - ], -} - -export function searchMentions(type, term) { - const pool = MOCK_MENTION_MAP[type] ?? [] - const q = term.trim().toLowerCase() - const filtered = q ? pool.filter((item) => item.title.toLowerCase().includes(q)) : pool - return filtered.slice(0, 10).map((item) => ({ stage: 'search', type, ...item })) +export async function searchMentions(type, term) { + const q = term.trim() + if (!q) return [] + const results = await apiSearchMentions({ type, q }) + return results.map((item) => ({ stage: 'search', ...item })) } diff --git a/src/components/post/editor/slashCommand.jsx b/src/components/post/editor/slashCommand.jsx index bca0fbb..b12cd0f 100644 --- a/src/components/post/editor/slashCommand.jsx +++ b/src/components/post/editor/slashCommand.jsx @@ -38,6 +38,7 @@ const SlashCommand = Extension.create({ char: '/', allowSpaces: true, startOfLine: false, + debounce: 200, items: ({ query }) => { const parsed = parseSlashQuery(query) if (parsed.stage === 'type') return filterTypeOptions(parsed.typeQuery) From ad74a9cb51ad923156e9da90687242a0e7dbe2a9 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 00:35:32 +0900 Subject: [PATCH 12/50] =?UTF-8?q?[feat]=20#157=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1=20API=20=EC=8B=A4=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/posts 배포 완료에 따라 작성 폼의 mock 제출 로직을 실 API 호출로 교체. 등록 성공 시 응답으로 받은 id로 상세 페이지로 이동하고, 실패 시 에러 메시지를 노출한다. 중복 제출 방지를 위해 요청 중에는 등록 버튼을 비활성화한다. Co-Authored-By: Claude Sonnet 5 --- src/pages/PostWritePage.jsx | 28 +++++++++++++++++++++------- src/services/postApi.js | 5 +++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx index 0cf52a0..67d9e0d 100644 --- a/src/pages/PostWritePage.jsx +++ b/src/pages/PostWritePage.jsx @@ -1,9 +1,11 @@ import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useMutation } from '@tanstack/react-query' import PostEditor from '@/components/post/editor/PostEditor' import { extractEntityTags } from '@/components/post/editor/extractEntityTags' import usePageMeta from '@/hooks/usePageMeta' +import { createPost } from '@/services/postApi' import { ROUTES } from '@/constants/routes' import { CATEGORIES_REQUIRING_MENTION, MENTION_TYPE_LABEL, POST_CATEGORY_LABEL } from '@/constants/post' import styles from './PostWritePage.module.css' @@ -26,6 +28,16 @@ function PostWritePage() { path: '/community/write', }) + const createMutation = useMutation({ + mutationFn: createPost, + onSuccess: ({ id }) => { + navigate(ROUTES.COMMUNITY_DETAIL(id)) + }, + onError: () => { + setError('게시글 등록에 실패했습니다. 잠시 후 다시 시도해주세요.') + }, + }) + function handleSubmit() { if (!title.trim()) { setError('제목을 입력해주세요.') @@ -37,15 +49,12 @@ function PostWritePage() { } setError(null) - // TODO: API 연동 후 제거 — POST /api/posts 배포 완료 시 실 연동 - const payload = { + createMutation.mutate({ category, title: title.trim(), content: contentJson, entityTags: entityTags.map(({ entityType, entityId }) => ({ entityType, entityId })), - } - console.info('[mock] POST /api/posts', payload) - navigate(ROUTES.COMMUNITY) + }) } return ( @@ -101,8 +110,13 @@ function PostWritePage() { {error &&

{error}

}
-
diff --git a/src/services/postApi.js b/src/services/postApi.js index 5a5ca07..9931bd6 100644 --- a/src/services/postApi.js +++ b/src/services/postApi.js @@ -10,6 +10,11 @@ export async function getPost(id) { return data } +export async function createPost(payload) { + const { data } = await api.post('/posts', payload) + return data +} + export async function searchMentions(params) { const { data } = await api.get('/mentions/search', { params }) return data From fa9123f89280a831ab1c7c743e8a66bb9f683271 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 00:40:16 +0900 Subject: [PATCH 13/50] =?UTF-8?q?[feat]=20#157=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=EC=8B=A4=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH/DELETE /api/posts/{id} 배포 완료에 따라 상세 페이지의 수정·삭제 버튼을 실 API에 연결. /community/:id/edit 라우트를 추가하고 PostWritePage가 작성/수정 모드를 함께 지원하도록 확장해 기존 게시글 데이터를 프리필한다. 삭제는 confirm 후 실행하며, 수정 완료 시 캐시된 상세/목록 쿼리를 무효화해 최신 내용이 즉시 반영되도록 한다. Co-Authored-By: Claude Sonnet 5 --- src/App.jsx | 1 + src/constants/routes.js | 1 + src/pages/PostDetailPage.jsx | 33 +++++++++--- src/pages/PostWritePage.jsx | 87 +++++++++++++++++++++++++----- src/pages/PostWritePage.module.css | 44 +++++++++++++++ src/services/postApi.js | 10 ++++ 6 files changed, 158 insertions(+), 18 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index c7a5e06..9b1634c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -70,6 +70,7 @@ function App() { 통합 검색
} /> } /> } /> + } /> } /> } /> } /> diff --git a/src/constants/routes.js b/src/constants/routes.js index e1dfc47..0ee796a 100644 --- a/src/constants/routes.js +++ b/src/constants/routes.js @@ -13,6 +13,7 @@ export const ROUTES = { COMMUNITY: '/community', COMMUNITY_DETAIL: (id) => `/community/${id}`, COMMUNITY_WRITE: '/community/write', + COMMUNITY_EDIT: (id) => `/community/${id}/edit`, ME: '/me', ME_UPCOMING: '/me/concerts/upcoming', ME_HISTORY: '/me/concerts/history', diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index c8a97dc..5cbed86 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -1,12 +1,12 @@ import { useState } from 'react' -import { useParams } from 'react-router-dom' -import { useQuery, useMutation } from '@tanstack/react-query' +import { useNavigate, useParams } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import BackButton from '@/components/ui/BackButton' import EmptyState from '@/components/ui/EmptyState' import PostContentView from '@/components/post/PostContentView' import usePageMeta from '@/hooks/usePageMeta' -import { getPost, recommendPost, unrecommendPost } from '@/services/postApi' +import { deletePost, getPost, recommendPost, unrecommendPost } from '@/services/postApi' import { ROUTES } from '@/constants/routes' import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' import { formatDateTime } from '@/utils/date' @@ -17,6 +17,8 @@ import styles from './PostDetailPage.module.css' function PostDetailPage() { const { id } = useParams() const postId = Number(id) + const navigate = useNavigate() + const queryClient = useQueryClient() const [isRecommended, setIsRecommended] = useState(false) const [recommendCount, setRecommendCount] = useState(0) const [syncedPost, setSyncedPost] = useState(null) @@ -37,12 +39,23 @@ function PostDetailPage() { const recommendMutation = useMutation({ mutationFn: (next) => (next ? recommendPost(postId) : unrecommendPost(postId)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, onError: (_err, next) => { setIsRecommended(!next) setRecommendCount((prev) => prev + (next ? -1 : 1)) }, }) + const deleteMutation = useMutation({ + mutationFn: () => deletePost(postId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + navigate(ROUTES.COMMUNITY) + }, + }) + usePageMeta({ title: post ? `${post.title} - 커밍` : undefined, description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', @@ -83,11 +96,12 @@ function PostDetailPage() { } function handleEdit() { - // TODO: 수정 시 기존 내용 프리필 — 작성 폼에 편집 모드 추가 후 연결 + navigate(ROUTES.COMMUNITY_EDIT(postId)) } function handleDelete() { - // TODO: API 연동 후 제거 — DELETE /api/posts/{id} 배포 완료 시 실 연동 + if (!window.confirm('게시글을 삭제하시겠습니까?')) return + deleteMutation.mutate() } return ( @@ -111,7 +125,14 @@ function PostDetailPage() { {post.isAuthor && (
- +
)}
diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx index 67d9e0d..d8cb403 100644 --- a/src/pages/PostWritePage.jsx +++ b/src/pages/PostWritePage.jsx @@ -1,11 +1,12 @@ import { useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { useMutation } from '@tanstack/react-query' +import { useNavigate, useParams } from 'react-router-dom' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import PostEditor from '@/components/post/editor/PostEditor' import { extractEntityTags } from '@/components/post/editor/extractEntityTags' +import EmptyState from '@/components/ui/EmptyState' import usePageMeta from '@/hooks/usePageMeta' -import { createPost } from '@/services/postApi' +import { createPost, getPost, updatePost } from '@/services/postApi' import { ROUTES } from '@/constants/routes' import { CATEGORIES_REQUIRING_MENTION, MENTION_TYPE_LABEL, POST_CATEGORY_LABEL } from '@/constants/post' import styles from './PostWritePage.module.css' @@ -13,31 +14,65 @@ import styles from './PostWritePage.module.css' const CATEGORIES = ['REVIEW', 'INFO', 'FREE'] function PostWritePage() { + const { id } = useParams() + const isEditMode = Boolean(id) + const postId = isEditMode ? Number(id) : null const navigate = useNavigate() + const queryClient = useQueryClient() const [title, setTitle] = useState('') const [category, setCategory] = useState('REVIEW') const [contentJson, setContentJson] = useState(null) const [error, setError] = useState(null) + const [formReady, setFormReady] = useState(!isEditMode) + + const { data: existingPost, isLoading: isLoadingPost, isError: isPostError } = useQuery({ + queryKey: ['post', postId], + queryFn: () => getPost(postId), + enabled: isEditMode, + retry: false, + }) + + if (isEditMode && existingPost && !formReady) { + setFormReady(true) + setTitle(existingPost.title) + setCategory(existingPost.category) + setContentJson(existingPost.content) + } const entityTags = useMemo(() => extractEntityTags(contentJson), [contentJson]) const mentionRequired = CATEGORIES_REQUIRING_MENTION.includes(category) usePageMeta({ - title: '글쓰기 - 커밍', + title: isEditMode ? '게시글 수정 - 커밍' : '글쓰기 - 커밍', description: '공연 후기, 정보·제보, 자유 이야기를 남겨보세요.', - path: '/community/write', + path: isEditMode ? `/community/${postId}/edit` : '/community/write', }) const createMutation = useMutation({ mutationFn: createPost, - onSuccess: ({ id }) => { - navigate(ROUTES.COMMUNITY_DETAIL(id)) + onSuccess: ({ id: newId }) => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + navigate(ROUTES.COMMUNITY_DETAIL(newId)) }, onError: () => { setError('게시글 등록에 실패했습니다. 잠시 후 다시 시도해주세요.') }, }) + const updateMutation = useMutation({ + mutationFn: (payload) => updatePost(postId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['post', postId] }) + queryClient.invalidateQueries({ queryKey: ['posts'] }) + navigate(ROUTES.COMMUNITY_DETAIL(postId)) + }, + onError: () => { + setError('게시글 수정에 실패했습니다. 잠시 후 다시 시도해주세요.') + }, + }) + + const isSubmitting = createMutation.isPending || updateMutation.isPending + function handleSubmit() { if (!title.trim()) { setError('제목을 입력해주세요.') @@ -49,19 +84,47 @@ function PostWritePage() { } setError(null) - createMutation.mutate({ + const payload = { category, title: title.trim(), content: contentJson, entityTags: entityTags.map(({ entityType, entityId }) => ({ entityType, entityId })), - }) + } + + if (isEditMode) { + updateMutation.mutate(payload) + } else { + createMutation.mutate(payload) + } + } + + if (isEditMode && isPostError) { + return ( + + ) + } + + if (isEditMode && (isLoadingPost || !formReady)) { + return ( +
+
+
+
+
+
+
+
+ ) } return (
-

글쓰기

+

{isEditMode ? '게시글 수정' : '글쓰기'}

@@ -114,9 +177,9 @@ function PostWritePage() { type="button" className={styles.submitBtn} onClick={handleSubmit} - disabled={createMutation.isPending} + disabled={isSubmitting} > - {createMutation.isPending ? '등록 중...' : '등록'} + {isSubmitting ? '저장 중...' : isEditMode ? '수정 완료' : '등록'}
diff --git a/src/pages/PostWritePage.module.css b/src/pages/PostWritePage.module.css index 84a4cf4..bf3e951 100644 --- a/src/pages/PostWritePage.module.css +++ b/src/pages/PostWritePage.module.css @@ -20,6 +20,50 @@ letter-spacing: -0.02em; } +/* ── 로딩 스켈레톤 (수정 모드) ── */ +@keyframes shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} + +.skeletonTitle, +.skeletonBar, +.skeletonInput, +.skeletonBody { + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + var(--color-surface) 25%, + var(--color-surface-hover) 50%, + var(--color-surface) 75% + ); + background-size: 800px 100%; + animation: shimmer 1.4s infinite linear; +} + +.skeletonTitle { + width: 160px; + height: 30px; + margin-bottom: 1.75rem; +} + +.skeletonBar { + width: 220px; + height: 32px; + margin-bottom: 1rem; +} + +.skeletonInput { + width: 100%; + height: 48px; + margin-bottom: 0.875rem; +} + +.skeletonBody { + width: 100%; + height: 240px; +} + /* ── 카테고리 선택 ── */ .categoryBar { display: flex; diff --git a/src/services/postApi.js b/src/services/postApi.js index 9931bd6..741ccec 100644 --- a/src/services/postApi.js +++ b/src/services/postApi.js @@ -15,6 +15,16 @@ export async function createPost(payload) { return data } +export async function updatePost(id, payload) { + const { data } = await api.patch(`/posts/${id}`, payload) + return data +} + +export async function deletePost(id) { + const { data } = await api.delete(`/posts/${id}`) + return data +} + export async function searchMentions(params) { const { data } = await api.get('/mentions/search', { params }) return data From 739229e1e4f8266b2eaf1d39f07df2cb3730e142 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 00:41:15 +0900 Subject: [PATCH 14/50] =?UTF-8?q?[fix]=20#157=20=EB=B3=B8=EB=AC=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=EC=96=B4=EC=9E=88=EB=8A=94=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=A0=9C=EC=B6=9C=20=EC=8B=9C=20=EB=AA=85?= =?UTF-8?q?=ED=99=95=ED=95=9C=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자유 게시판처럼 멘션 태그가 필수가 아닌 카테고리에서 본문에 아무 내용도 입력하지 않고 제출하면 BE가 400으로 거부해 원인을 알 수 없는 등록 실패 메시지만 노출되던 문제. 제출 전 프론트에서 본문이 비어 있는지 먼저 검사해 구체적인 안내 문구를 보여준다. Co-Authored-By: Claude Sonnet 5 --- .../post/editor/extractEntityTags.js | 24 +++++++++++++++++++ src/pages/PostWritePage.jsx | 6 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/components/post/editor/extractEntityTags.js b/src/components/post/editor/extractEntityTags.js index 9f84d73..b6f9e64 100644 --- a/src/components/post/editor/extractEntityTags.js +++ b/src/components/post/editor/extractEntityTags.js @@ -22,3 +22,27 @@ export function extractEntityTags(contentJson) { walk(contentJson) return tags } + +// 본문에 텍스트나 엔티티 멘션이 하나도 없는 완전히 빈 문서인지 확인한다 +// (BE가 빈 content를 거부하므로, 제출 전 프론트에서 먼저 막아 안내한다) +export function isContentEmpty(contentJson) { + let hasContent = false + + function walk(node) { + if (!node || hasContent) return + + if (node.type === 'text' && node.text?.trim()) { + hasContent = true + return + } + if (node.type === 'entityMentionCard' || node.type === 'entityMentionChip') { + hasContent = true + return + } + + node.content?.forEach(walk) + } + + walk(contentJson) + return !hasContent +} diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx index d8cb403..facd39d 100644 --- a/src/pages/PostWritePage.jsx +++ b/src/pages/PostWritePage.jsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import PostEditor from '@/components/post/editor/PostEditor' -import { extractEntityTags } from '@/components/post/editor/extractEntityTags' +import { extractEntityTags, isContentEmpty } from '@/components/post/editor/extractEntityTags' import EmptyState from '@/components/ui/EmptyState' import usePageMeta from '@/hooks/usePageMeta' import { createPost, getPost, updatePost } from '@/services/postApi' @@ -78,6 +78,10 @@ function PostWritePage() { setError('제목을 입력해주세요.') return } + if (isContentEmpty(contentJson)) { + setError('본문을 입력해주세요.') + return + } if (mentionRequired && entityTags.length === 0) { setError(`${POST_CATEGORY_LABEL[category]} 게시글은 공연·아티스트·음악을 1개 이상 태그해야 해요. 본문에 '/'를 입력해 태그해보세요.`) return From 62abe1799e8c4a1f4f524c5dde06f8e90e83db1c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 01:44:55 +0900 Subject: [PATCH 15/50] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B0=B0=EC=A7=80=20?= =?UTF-8?q?=EC=83=89=EC=83=81=EC=9D=84=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=EC=97=90=20=EB=A7=9E=EB=8A=94=20?= =?UTF-8?q?=EC=A4=91=EB=A6=BD=20=ED=86=A4=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 보라/파랑/초록 계열 배지 색이 로즈 액센트 중심의 디자인 시스템과 어울리지 않아, 카테고리별 하드코딩 색상을 제거하고 기존 surface-hover / text-muted 토큰을 재사용한 중립 배지로 통일. Co-Authored-By: Claude Sonnet 5 --- src/components/post/PostListItem.jsx | 4 ++-- src/components/post/PostListItem.module.css | 3 ++- src/constants/post.js | 6 ------ src/pages/PostDetailPage.jsx | 4 ++-- src/pages/PostDetailPage.module.css | 3 ++- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/components/post/PostListItem.jsx b/src/components/post/PostListItem.jsx index e5a27a8..0bd5e81 100644 --- a/src/components/post/PostListItem.jsx +++ b/src/components/post/PostListItem.jsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom' import { ROUTES } from '@/constants/routes' -import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' +import { POST_CATEGORY_LABEL } from '@/constants/post' import { formatPostDate } from '@/utils/date' import styles from './PostListItem.module.css' @@ -11,7 +11,7 @@ function PostListItem({ post }) { return (
- + {POST_CATEGORY_LABEL[category] ?? category} {title} diff --git a/src/components/post/PostListItem.module.css b/src/components/post/PostListItem.module.css index 71757c7..77f60b7 100644 --- a/src/components/post/PostListItem.module.css +++ b/src/components/post/PostListItem.module.css @@ -28,7 +28,8 @@ font-size: 0.7rem; font-weight: 700; letter-spacing: 0.03em; - color: var(--color-on-accent); + color: var(--color-text-muted); + background: var(--color-surface-hover); border-radius: var(--radius-xs); line-height: 1.4; } diff --git a/src/constants/post.js b/src/constants/post.js index 3533080..f9f6f60 100644 --- a/src/constants/post.js +++ b/src/constants/post.js @@ -4,12 +4,6 @@ export const POST_CATEGORY_LABEL = { FREE: '자유', } -export const POST_CATEGORY_COLOR = { - REVIEW: '#7C3AED', - INFO: '#0284C7', - FREE: '#16A34A', -} - // 후기·정보는 엔티티 멘션 최소 1개 필수 export const CATEGORIES_REQUIRING_MENTION = ['REVIEW', 'INFO'] diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index 5cbed86..9c2f168 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -8,7 +8,7 @@ import PostContentView from '@/components/post/PostContentView' import usePageMeta from '@/hooks/usePageMeta' import { deletePost, getPost, recommendPost, unrecommendPost } from '@/services/postApi' import { ROUTES } from '@/constants/routes' -import { POST_CATEGORY_COLOR, POST_CATEGORY_LABEL } from '@/constants/post' +import { POST_CATEGORY_LABEL } from '@/constants/post' import { formatDateTime } from '@/utils/date' import useAuthStore from '@/stores/authStore' import useLoginModalStore from '@/stores/loginModalStore' @@ -110,7 +110,7 @@ function PostDetailPage() {
- + {POST_CATEGORY_LABEL[post.category]} {formatDateTime(post.createdAt)} diff --git a/src/pages/PostDetailPage.module.css b/src/pages/PostDetailPage.module.css index 9b87694..594eada 100644 --- a/src/pages/PostDetailPage.module.css +++ b/src/pages/PostDetailPage.module.css @@ -56,7 +56,8 @@ font-size: 0.7rem; font-weight: 700; letter-spacing: 0.03em; - color: var(--color-on-accent); + color: var(--color-text-muted); + background: var(--color-surface-hover); border-radius: var(--radius-xs); line-height: 1.4; } From 6e9e83eca6514823c50a23579ee2dcb05c1ec9e7 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 01:46:07 +0900 Subject: [PATCH 16/50] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=90=EC=9C=A0=C2=B7=EC=A0=95=EB=B3=B4=C2=B7?= =?UTF-8?q?=ED=9B=84=EA=B8=B0=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20"=EC=A0=95=EB=B3=B4"=20=EB=9D=BC=EB=B2=A8=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 글쓰기 폼 탭과 목록 필터 탭의 카테고리 순서가 서로 달라 일관성이 없었음. 자유 → 정보 → 후기 순으로 통일하고, "정보·제보" 라벨을 "정보"로 축약. Co-Authored-By: Claude Sonnet 5 --- src/constants/post.js | 4 ++-- src/pages/PostDetailPage.jsx | 2 +- src/pages/PostWritePage.jsx | 6 +++--- src/pages/PostsPage.jsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/constants/post.js b/src/constants/post.js index f9f6f60..61934cc 100644 --- a/src/constants/post.js +++ b/src/constants/post.js @@ -1,7 +1,7 @@ export const POST_CATEGORY_LABEL = { - REVIEW: '후기', - INFO: '정보·제보', FREE: '자유', + INFO: '정보', + REVIEW: '후기', } // 후기·정보는 엔티티 멘션 최소 1개 필수 diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index 9c2f168..ff50780 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -58,7 +58,7 @@ function PostDetailPage() { usePageMeta({ title: post ? `${post.title} - 커밍` : undefined, - description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', + description: '자유, 정보, 공연 후기 이야기를 나누는 Jpop 팬 커뮤니티입니다.', path: `/community/${postId}`, }) diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx index facd39d..798380e 100644 --- a/src/pages/PostWritePage.jsx +++ b/src/pages/PostWritePage.jsx @@ -11,7 +11,7 @@ import { ROUTES } from '@/constants/routes' import { CATEGORIES_REQUIRING_MENTION, MENTION_TYPE_LABEL, POST_CATEGORY_LABEL } from '@/constants/post' import styles from './PostWritePage.module.css' -const CATEGORIES = ['REVIEW', 'INFO', 'FREE'] +const CATEGORIES = ['FREE', 'INFO', 'REVIEW'] function PostWritePage() { const { id } = useParams() @@ -20,7 +20,7 @@ function PostWritePage() { const navigate = useNavigate() const queryClient = useQueryClient() const [title, setTitle] = useState('') - const [category, setCategory] = useState('REVIEW') + const [category, setCategory] = useState('FREE') const [contentJson, setContentJson] = useState(null) const [error, setError] = useState(null) const [formReady, setFormReady] = useState(!isEditMode) @@ -44,7 +44,7 @@ function PostWritePage() { usePageMeta({ title: isEditMode ? '게시글 수정 - 커밍' : '글쓰기 - 커밍', - description: '공연 후기, 정보·제보, 자유 이야기를 남겨보세요.', + description: '자유, 정보, 공연 후기 이야기를 남겨보세요.', path: isEditMode ? `/community/${postId}/edit` : '/community/write', }) diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index 93e4c76..132c44c 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -13,7 +13,7 @@ import useAuthStore from '@/stores/authStore' import useLoginModalStore from '@/stores/loginModalStore' import styles from './PostsPage.module.css' -const CATEGORY_FILTERS = ['ALL', 'REVIEW', 'INFO', 'FREE'] +const CATEGORY_FILTERS = ['ALL', 'FREE', 'INFO', 'REVIEW'] const ITEMS_PER_PAGE = 20 function PostsPage() { @@ -29,7 +29,7 @@ function PostsPage() { usePageMeta({ title: '커뮤니티 - 커밍', - description: '공연 후기, 정보·제보, 자유 이야기를 나누는 Jpop 팬 커뮤니티입니다.', + description: '자유, 정보, 공연 후기 이야기를 나누는 Jpop 팬 커뮤니티입니다.', path: '/community', }) From 4b56abed0741a79a56feca09c98b048e01880dbb Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 01:46:44 +0900 Subject: [PATCH 17/50] =?UTF-8?q?[chore]=20=EA=B8=80=EC=93=B0=EA=B8=B0?= =?UTF-8?q?=C2=B7=EB=93=B1=EB=A1=9D=20=EB=B2=84=ED=8A=BC=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20Button=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 버튼이 각 CSS 모듈에 거의 동일한 스타일(패딩·폰트 크기만 미묘하게 다름)을 중복 정의하고 있어 이질적으로 보이던 문제를 해결. variant/size props를 가진 components/ui/Button으로 통합해 재사용. Co-Authored-By: Claude Sonnet 5 --- src/components/ui/Button.jsx | 8 +++++++ src/components/ui/Button.module.css | 37 +++++++++++++++++++++++++++++ src/pages/PostWritePage.jsx | 7 +++--- src/pages/PostWritePage.module.css | 14 ----------- src/pages/PostsPage.jsx | 5 ++-- src/pages/PostsPage.module.css | 11 --------- 6 files changed, 52 insertions(+), 30 deletions(-) create mode 100644 src/components/ui/Button.jsx create mode 100644 src/components/ui/Button.module.css diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx new file mode 100644 index 0000000..8f0934d --- /dev/null +++ b/src/components/ui/Button.jsx @@ -0,0 +1,8 @@ +import styles from './Button.module.css' + +function Button({ variant = 'primary', size = 'md', className = '', ...props }) { + const classes = [styles.btn, styles[variant], styles[size], className].filter(Boolean).join(' ') + return +
diff --git a/src/pages/PostWritePage.module.css b/src/pages/PostWritePage.module.css index bf3e951..b099780 100644 --- a/src/pages/PostWritePage.module.css +++ b/src/pages/PostWritePage.module.css @@ -170,17 +170,3 @@ justify-content: flex-end; margin-top: 1.5rem; } - -.submitBtn { - padding: 0.625rem 1.75rem; - border-radius: var(--radius-lg); - background: var(--color-accent); - color: var(--color-on-accent); - font-size: 0.9375rem; - font-weight: 600; - transition: background-color 0.15s; -} - -.submitBtn:hover { - background: var(--color-accent-dark); -} diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index 132c44c..487059d 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -1,6 +1,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' +import Button from '@/components/ui/Button' import EmptyState from '@/components/ui/EmptyState' import Pagination from '@/components/ui/Pagination' import PostListItem from '@/components/post/PostListItem' @@ -81,9 +82,9 @@ function PostsPage() {

커뮤니티

- +
diff --git a/src/pages/PostsPage.module.css b/src/pages/PostsPage.module.css index a4ac1c5..45b860d 100644 --- a/src/pages/PostsPage.module.css +++ b/src/pages/PostsPage.module.css @@ -27,17 +27,6 @@ .writeBtn { flex-shrink: 0; - padding: 0.5rem 1.125rem; - border-radius: var(--radius-lg); - background: var(--color-accent); - color: var(--color-on-accent); - font-size: 0.875rem; - font-weight: 600; - transition: background-color 0.15s; -} - -.writeBtn:hover { - background: var(--color-accent-dark); } /* ── 카테고리 필터 ── */ From 9f6d7c3d788a71dd02c8235e3addc1458dc0a717 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 01:46:53 +0900 Subject: [PATCH 18/50] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=8B=9C=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=97=90=20=EB=82=A8=EC=95=84=EC=9E=88?= =?UTF-8?q?=EB=8D=98=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=95=EC=A1=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit border-color는 중립색으로 바꿨지만, 전역 :focus-visible 규칙(outline: 2px solid --color-accent)과 specificity가 동률이라 로드 순서에 따라 로즈핑크 outline이 여전히 노출됨. 제목 입력창(.titleInput)과 본문 에디터(.prose)에 :focus-visible outline: none을 명시적으로 덮어써 확실히 제거. Co-Authored-By: Claude Sonnet 5 --- src/components/post/editor/PostEditor.module.css | 6 +++++- src/pages/PostWritePage.module.css | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/post/editor/PostEditor.module.css b/src/components/post/editor/PostEditor.module.css index 1da4abf..ab9152a 100644 --- a/src/components/post/editor/PostEditor.module.css +++ b/src/components/post/editor/PostEditor.module.css @@ -6,7 +6,7 @@ } .wrap:focus-within { - border-color: var(--color-accent); + border-color: var(--color-text-muted); } .toolbar { @@ -58,6 +58,10 @@ outline: none; } +.prose:focus-visible { + outline: none; +} + .prose p { margin: 0 0 0.75rem; } diff --git a/src/pages/PostWritePage.module.css b/src/pages/PostWritePage.module.css index b099780..dc723a6 100644 --- a/src/pages/PostWritePage.module.css +++ b/src/pages/PostWritePage.module.css @@ -113,7 +113,11 @@ } .titleInput:focus { - border-color: var(--color-accent); + border-color: var(--color-text-muted); +} + +.titleInput:focus-visible { + outline: none; } .titleInput::placeholder { From 23fc9808c8a353f357df5123db268b4f79a861b0 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 02:03:28 +0900 Subject: [PATCH 19/50] =?UTF-8?q?[fix]=20=EC=A0=9C=EB=AA=A9=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=B0=BD=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=8B=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=84=9C=EB=A6=AC=20=EB=B0=98=EA=B2=BD=EC=9D=B4=20?= =?UTF-8?q?=EC=A4=84=EC=96=B4=EB=93=9C=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전역 :focus-visible 규칙(border-radius: var(--radius-xs))과 .titleInput의 기본 반경(--radius-lg)이 specificity 동률이라, 포커스 시 모서리가 12px에서 4px로 줄어들어 기본 상태와 달라 보였음. .titleInput:focus-visible에 반경을 명시해 고정. Co-Authored-By: Claude Sonnet 5 --- src/pages/PostWritePage.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/PostWritePage.module.css b/src/pages/PostWritePage.module.css index dc723a6..598f0d9 100644 --- a/src/pages/PostWritePage.module.css +++ b/src/pages/PostWritePage.module.css @@ -118,6 +118,7 @@ .titleInput:focus-visible { outline: none; + border-radius: var(--radius-lg); } .titleInput::placeholder { From e4192a8ffa219d67e219f6dd6932b97bbb763423 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 03:07:28 +0900 Subject: [PATCH 20/50] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=EC=97=90=20=EB=A7=88=ED=81=AC?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=20=EC=84=9C=EC=8B=9D=C2=B7=EC=83=89=EC=83=81?= =?UTF-8?q?=C2=B7=EB=A7=81=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상단 툴바 대신 '/' 슬래시 커맨드로 제목·목록·인용 등 블록 서식을 지정하고, 텍스트 선택 시 나타나는 버블 메뉴로 굵게·기울임·링크·글자 색상을 적용하도록 구현. 읽기 전용 렌더러에도 동일한 스타일을 반영해 작성·열람 화면 간 표시를 일치시켰다. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 42 +++++ package.json | 3 + src/components/post/PostContentView.jsx | 4 +- .../post/PostContentView.module.css | 61 ++++++++ .../post/editor/EditorBubbleMenu.jsx | 146 ++++++++++++++++++ .../post/editor/EditorBubbleMenu.module.css | 115 ++++++++++++++ src/components/post/editor/PostEditor.jsx | 41 +++-- .../post/editor/PostEditor.module.css | 102 +++++++----- .../post/editor/SlashCommandMenu.jsx | 9 +- .../post/editor/SlashCommandMenu.module.css | 15 ++ src/components/post/editor/mentionSearch.js | 20 ++- src/components/post/editor/slashCommand.jsx | 15 ++ src/components/post/editor/textColors.js | 12 ++ 13 files changed, 516 insertions(+), 69 deletions(-) create mode 100644 src/components/post/editor/EditorBubbleMenu.jsx create mode 100644 src/components/post/editor/EditorBubbleMenu.module.css create mode 100644 src/components/post/editor/textColors.js diff --git a/package-lock.json b/package-lock.json index 8eda7ed..06455e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "@tanstack/react-query": "^5.96.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-color": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/extension-text-style": "^3.31.3", "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", @@ -1072,6 +1075,19 @@ "@tiptap/pm": "3.31.3" } }, + "node_modules/@tiptap/extension-color": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.31.3.tgz", + "integrity": "sha512-rsZ/HlCYagMoyqn8kKxLYaZwHvowGUTeHVVxEuMJqTantIrnnQMSQUA3a7+X22l2eD7ec/LlG4bBGPjw+gf3tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "3.31.3" + } + }, "node_modules/@tiptap/extension-document": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", @@ -1263,6 +1279,19 @@ "@tiptap/core": "3.31.3" } }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz", + "integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, "node_modules/@tiptap/extension-strike": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", @@ -1289,6 +1318,19 @@ "@tiptap/core": "3.31.3" } }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.31.3.tgz", + "integrity": "sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, "node_modules/@tiptap/extension-underline": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", diff --git a/package.json b/package.json index 54eb15f..e86fea9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "dependencies": { "@tanstack/react-query": "^5.96.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-color": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/extension-text-style": "^3.31.3", "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", diff --git a/src/components/post/PostContentView.jsx b/src/components/post/PostContentView.jsx index 4ae6329..a957372 100644 --- a/src/components/post/PostContentView.jsx +++ b/src/components/post/PostContentView.jsx @@ -1,5 +1,7 @@ import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' +import Color from '@tiptap/extension-color' +import { TextStyle } from '@tiptap/extension-text-style' import { EntityMentionCard, EntityMentionChip } from './editor/entityMentionExtensions' import styles from './PostContentView.module.css' @@ -8,7 +10,7 @@ import styles from './PostContentView.module.css' // 멘션 카드 NodeView를 작성/열람 양쪽에서 동일하게 재사용하기 위함 function PostContentView({ content }) { const editor = useEditor({ - extensions: [StarterKit, EntityMentionCard, EntityMentionChip], + extensions: [StarterKit, TextStyle, Color, EntityMentionCard, EntityMentionChip], content, editable: false, }) diff --git a/src/components/post/PostContentView.module.css b/src/components/post/PostContentView.module.css index 9dc8cc5..583c555 100644 --- a/src/components/post/PostContentView.module.css +++ b/src/components/post/PostContentView.module.css @@ -15,3 +15,64 @@ .prose :global(p:last-child) { margin-bottom: 0; } + +.prose :global(h1), +.prose :global(h2), +.prose :global(h3) { + margin: 1.5rem 0 0.75rem; + font-weight: 700; + line-height: 1.35; + color: var(--color-text); +} + +.prose :global(h1:first-child), +.prose :global(h2:first-child), +.prose :global(h3:first-child) { + margin-top: 0; +} + +.prose :global(h1) { + font-size: 1.5rem; +} + +.prose :global(h2) { + font-size: 1.3125rem; +} + +.prose :global(h3) { + font-size: 1.1875rem; +} + +.prose :global(ul), +.prose :global(ol) { + margin: 0 0 0.875rem; + padding-left: 1.25rem; +} + +.prose :global(li) { + margin: 0.3125rem 0; +} + +.prose :global(li p) { + margin: 0; +} + +.prose :global(blockquote) { + margin: 0 0 0.875rem; + padding-left: 0.875rem; + border-left: 3px solid var(--color-border); + color: var(--color-text-muted); +} + +.prose :global(a) { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* 엔티티 멘션 카드·칩은 NodeViewWrapper로 감싸진 자체 링크 스타일을 쓰므로 + 위 범용 링크 스타일(빨간 밑줄)이 덮어쓰지 않도록 분리한다 */ +.prose :global([data-node-view-wrapper] a) { + color: inherit; + text-decoration: none; +} diff --git a/src/components/post/editor/EditorBubbleMenu.jsx b/src/components/post/editor/EditorBubbleMenu.jsx new file mode 100644 index 0000000..7cfcdf3 --- /dev/null +++ b/src/components/post/editor/EditorBubbleMenu.jsx @@ -0,0 +1,146 @@ +import { BubbleMenu } from '@tiptap/react/menus' +import { TextSelection } from '@tiptap/pm/state' +import { useCallback, useEffect, useState } from 'react' + +import { TEXT_COLORS } from './textColors' +import styles from './EditorBubbleMenu.module.css' + +function shouldShow({ editor, state }) { + const { selection } = state + if (!(selection instanceof TextSelection) || selection.empty) return false + return editor.isEditable +} + +function LinkIcon() { + return ( + + + + + ) +} + +function EditorBubbleMenu({ editor }) { + const [mode, setMode] = useState('menu') + const [linkUrl, setLinkUrl] = useState('') + + // 새로운 선택 영역이 잡힐 때마다 링크 입력/색상 팔레트를 닫아 기본 메뉴로 되돌린다 + useEffect(() => { + if (!editor) return undefined + const resetMode = () => setMode('menu') + editor.on('selectionUpdate', resetMode) + return () => editor.off('selectionUpdate', resetMode) + }, [editor]) + + const openLinkEditor = useCallback(() => { + setLinkUrl(editor.getAttributes('link').href ?? '') + setMode('link') + }, [editor]) + + const applyLink = useCallback( + (e) => { + e.preventDefault() + const url = linkUrl.trim() + const chain = editor.chain().focus().extendMarkRange('link') + if (url) chain.setLink({ href: url }) + else chain.unsetLink() + chain.run() + setMode('menu') + }, + [editor, linkUrl], + ) + + const applyColor = useCallback( + (value) => { + const chain = editor.chain().focus() + if (value) chain.setColor(value) + else chain.unsetColor() + chain.run() + setMode('menu') + }, + [editor], + ) + + if (!editor) return null + + return ( + + {mode === 'link' && ( +
+ setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') setMode('menu') + }} + /> + +
+ )} + + {mode === 'color' && ( +
+ {TEXT_COLORS.map((c) => ( +
+ )} + + {mode === 'menu' && ( +
+ + + + +
+ )} +
+ ) +} + +export default EditorBubbleMenu diff --git a/src/components/post/editor/EditorBubbleMenu.module.css b/src/components/post/editor/EditorBubbleMenu.module.css new file mode 100644 index 0000000..b76a42d --- /dev/null +++ b/src/components/post/editor/EditorBubbleMenu.module.css @@ -0,0 +1,115 @@ +.menu { + display: flex; + padding: 0.25rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} + +.row { + display: flex; + gap: 0.125rem; +} + +.btn, +.btnActive { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + background: none; + border: none; + font-size: 0.9375rem; + color: var(--color-text-muted); + transition: background-color 0.15s, color 0.15s; +} + +.btn:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.btnActive { + background: var(--color-accent-dim); + color: var(--color-accent); +} + +.colorGlyph { + font-weight: 700; + font-size: 0.875rem; +} + +.linkForm { + display: flex; + gap: 0.375rem; + align-items: center; + padding: 0.125rem; +} + +.linkInput { + width: 220px; + padding: 0.375rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.8125rem; +} + +.linkInput:focus { + outline: none; + border-color: var(--color-accent); +} + +.linkConfirm { + flex-shrink: 0; + padding: 0.375rem 0.625rem; + border: none; + border-radius: var(--radius-sm); + background: var(--color-accent); + color: var(--color-on-accent); + font-size: 0.8125rem; + font-weight: 600; +} + +.colorRow { + display: grid; + grid-template-columns: repeat(5, 20px); + gap: 0.375rem; + padding: 0.125rem; +} + +.colorSwatch, +.colorSwatchDefault { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--color-border); +} + +.colorSwatchDefault { + background: var(--color-surface); + position: relative; +} + +.colorSwatchDefault::before, +.colorSwatchDefault::after { + content: ''; + position: absolute; + left: 3px; + right: 3px; + top: 50%; + height: 1px; + background: var(--color-text-muted); +} + +.colorSwatchDefault::before { + transform: rotate(45deg); +} + +.colorSwatchDefault::after { + transform: rotate(-45deg); +} diff --git a/src/components/post/editor/PostEditor.jsx b/src/components/post/editor/PostEditor.jsx index 1cae645..9347e0b 100644 --- a/src/components/post/editor/PostEditor.jsx +++ b/src/components/post/editor/PostEditor.jsx @@ -1,13 +1,30 @@ import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' +import Color from '@tiptap/extension-color' +import { TextStyle } from '@tiptap/extension-text-style' +import Placeholder from '@tiptap/extension-placeholder' import { EntityMentionCard, EntityMentionChip } from './entityMentionExtensions' +import EditorBubbleMenu from './EditorBubbleMenu' import SlashCommand from './slashCommand' import styles from './PostEditor.module.css' function PostEditor({ content, onChange }) { const editor = useEditor({ - extensions: [StarterKit, EntityMentionCard, EntityMentionChip, SlashCommand], + extensions: [ + StarterKit.configure({ link: { openOnClick: false } }), + TextStyle, + Color, + EntityMentionCard, + EntityMentionChip, + SlashCommand, + Placeholder.configure({ + placeholder: ({ editor: e, node }) => + e.isEmpty && node.type.name === 'paragraph' + ? "'/' 를 입력해 서식을 지정하거나 공연·아티스트·음악을 태그해보세요" + : '', + }), + ], content, onUpdate: ({ editor: e }) => onChange?.(e.getJSON()), editorProps: { @@ -21,27 +38,7 @@ function PostEditor({ content, onChange }) { return (
-
- - - / 를 입력해 공연·아티스트·음악을 태그해보세요 -
+
) diff --git a/src/components/post/editor/PostEditor.module.css b/src/components/post/editor/PostEditor.module.css index ab9152a..0702115 100644 --- a/src/components/post/editor/PostEditor.module.css +++ b/src/components/post/editor/PostEditor.module.css @@ -9,46 +9,6 @@ border-color: var(--color-text-muted); } -.toolbar { - display: flex; - gap: 0.25rem; - padding: 0.5rem; - border-bottom: 1px solid var(--color-border); -} - -.toolBtn, -.toolBtnActive { - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border-radius: var(--radius-sm); - background: none; - border: none; - font-size: 0.9375rem; - color: var(--color-text-muted); - transition: background-color 0.15s, color 0.15s; -} - -.toolBtn:hover { - background: var(--color-surface-hover); - color: var(--color-text); -} - -.toolBtnActive { - background: var(--color-accent-dim); - color: var(--color-accent); -} - -.hint { - display: flex; - align-items: center; - margin-left: 0.5rem; - font-size: 0.75rem; - color: var(--color-text-muted); -} - .prose { min-height: 320px; padding: 1rem 1.125rem 1.5rem; @@ -69,3 +29,65 @@ .prose p:last-child { margin-bottom: 0; } + +.prose h1, +.prose h2, +.prose h3 { + margin: 1.25rem 0 0.625rem; + font-weight: 700; + line-height: 1.35; + color: var(--color-text); +} + +.prose h1:first-child, +.prose h2:first-child, +.prose h3:first-child { + margin-top: 0; +} + +.prose h1 { + font-size: 1.375rem; +} + +.prose h2 { + font-size: 1.1875rem; +} + +.prose h3 { + font-size: 1.0625rem; +} + +.prose ul, +.prose ol { + margin: 0 0 0.75rem; + padding-left: 1.25rem; +} + +.prose li { + margin: 0.25rem 0; +} + +.prose li p { + margin: 0; +} + +.prose blockquote { + margin: 0 0 0.75rem; + padding-left: 0.875rem; + border-left: 3px solid var(--color-border); + color: var(--color-text-muted); +} + +.prose a { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.prose p:global(.is-editor-empty):first-child::before { + content: attr(data-placeholder); + float: left; + height: 0; + color: var(--color-text-muted); + pointer-events: none; +} diff --git a/src/components/post/editor/SlashCommandMenu.jsx b/src/components/post/editor/SlashCommandMenu.jsx index 26de91c..380d838 100644 --- a/src/components/post/editor/SlashCommandMenu.jsx +++ b/src/components/post/editor/SlashCommandMenu.jsx @@ -47,13 +47,18 @@ const SlashCommandMenu = forwardRef(function SlashCommandMenu({ items, command }
{items.map((item, i) => ( diff --git a/src/pages/PostsPage.module.css b/src/pages/PostsPage.module.css index 4c10a82..b3f543e 100644 --- a/src/pages/PostsPage.module.css +++ b/src/pages/PostsPage.module.css @@ -14,17 +14,27 @@ align-items: center; justify-content: space-between; gap: 0.75rem; + padding: 1.25rem 1.5rem; margin-bottom: 1.5rem; + background: var(--color-accent-dim); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); } .pageTitle { - margin: 0; - font-size: 1.875rem; + margin: 0 0 0.2rem; + font-size: 1.5rem; font-weight: 700; color: var(--color-text); letter-spacing: -0.02em; } +.pageDesc { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-muted); +} + .writeBtn { flex-shrink: 0; } @@ -79,3 +89,15 @@ flex-direction: column; gap: 0.75rem; } + +@media (max-width: 767px) { + .pageHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .writeBtn { + align-self: stretch; + } +} From 75b1cdc0d1a987d86bf5aba6ebd33cbd4100e8d0 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:41:23 +0900 Subject: [PATCH 24/50] =?UTF-8?q?[feat]=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EC=9A=B0=EC=B8=A1=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EB=B0=8F=20=EB=8B=A4?= =?UTF-8?q?=EA=B0=80=EC=98=A4=EB=8A=94=20=EA=B3=B5=EC=97=B0=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 페이지를 2컬럼 레이아웃(본문+사이드바)으로 확장하고, 사이드바 셸(PostsSidebar)과 "다가오는 공연" 위젯을 추가. 기존 콘서트 목록 API(getConcerts)를 재사용해 실 데이터를 바로 연동함. 사이드바는 1279px 미만에서 숨김 처리. Co-Authored-By: Claude Sonnet 5 --- src/components/post/sidebar/PostsSidebar.jsx | 12 +++ .../post/sidebar/PostsSidebar.module.css | 13 +++ .../post/sidebar/UpcomingConcertsWidget.jsx | 52 ++++++++++ .../sidebar/UpcomingConcertsWidget.module.css | 84 ++++++++++++++++ src/pages/PostsPage.jsx | 98 ++++++++++--------- src/pages/PostsPage.module.css | 20 +++- 6 files changed, 232 insertions(+), 47 deletions(-) create mode 100644 src/components/post/sidebar/PostsSidebar.jsx create mode 100644 src/components/post/sidebar/PostsSidebar.module.css create mode 100644 src/components/post/sidebar/UpcomingConcertsWidget.jsx create mode 100644 src/components/post/sidebar/UpcomingConcertsWidget.module.css diff --git a/src/components/post/sidebar/PostsSidebar.jsx b/src/components/post/sidebar/PostsSidebar.jsx new file mode 100644 index 0000000..8ac501b --- /dev/null +++ b/src/components/post/sidebar/PostsSidebar.jsx @@ -0,0 +1,12 @@ +import UpcomingConcertsWidget from './UpcomingConcertsWidget' +import styles from './PostsSidebar.module.css' + +function PostsSidebar() { + return ( + + ) +} + +export default PostsSidebar diff --git a/src/components/post/sidebar/PostsSidebar.module.css b/src/components/post/sidebar/PostsSidebar.module.css new file mode 100644 index 0000000..994672e --- /dev/null +++ b/src/components/post/sidebar/PostsSidebar.module.css @@ -0,0 +1,13 @@ +.sidebar { + display: flex; + flex-direction: column; + gap: 1rem; + width: 15.5rem; + flex-shrink: 0; +} + +@media (max-width: 1279px) { + .sidebar { + display: none; + } +} diff --git a/src/components/post/sidebar/UpcomingConcertsWidget.jsx b/src/components/post/sidebar/UpcomingConcertsWidget.jsx new file mode 100644 index 0000000..104b1de --- /dev/null +++ b/src/components/post/sidebar/UpcomingConcertsWidget.jsx @@ -0,0 +1,52 @@ +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' + +import { ROUTES } from '@/constants/routes' +import { getConcerts } from '@/services/concertApi' +import styles from './UpcomingConcertsWidget.module.css' + +const UPCOMING_COUNT = 3 + +function formatShortDate(startDate) { + const [, month, day] = startDate.split('-').map(Number) + return `${month}.${String(day).padStart(2, '0')}` +} + +function UpcomingConcertsWidget() { + const { data, isLoading } = useQuery({ + queryKey: ['upcoming-concerts-sidebar'], + queryFn: () => getConcerts({ status: 'UPCOMING', sort: 'startDate,asc', size: UPCOMING_COUNT }), + staleTime: 5 * 60 * 1000, + }) + + const concerts = data?.content ?? [] + + if (!isLoading && concerts.length === 0) return null + + return ( +
+

다가오는 공연

+ {isLoading ? ( +
+ {Array.from({ length: UPCOMING_COUNT }).map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {concerts.map((concert) => ( + + {formatShortDate(concert.startDate)} + + {concert.title} + {concert.venue} + + + ))} +
+ )} +
+ ) +} + +export default UpcomingConcertsWidget diff --git a/src/components/post/sidebar/UpcomingConcertsWidget.module.css b/src/components/post/sidebar/UpcomingConcertsWidget.module.css new file mode 100644 index 0000000..b3b8434 --- /dev/null +++ b/src/components/post/sidebar/UpcomingConcertsWidget.module.css @@ -0,0 +1,84 @@ +.widget { + padding: 1rem 1.125rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.widgetTitle { + margin: 0 0 0.625rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.widgetList { + display: flex; + flex-direction: column; +} + +.upcomingRow { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.5rem 0; + color: inherit; + text-decoration: none; + border-top: 1px solid var(--color-border); +} + +.upcomingRow:first-child { + border-top: none; +} + +.upcomingDate { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 700; + color: var(--color-accent); + font-variant-numeric: tabular-nums; +} + +.upcomingInfo { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.upcomingTitle { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upcomingVenue { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upcomingSkeleton { + height: 2.375rem; + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + var(--color-surface-hover) 25%, + var(--color-border) 50%, + var(--color-surface-hover) 75% + ); + background-size: 800px 100%; + animation: shimmer 1.4s infinite linear; +} + +@keyframes shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index dcdb50f..cb23007 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -6,6 +6,7 @@ import EmptyState from '@/components/ui/EmptyState' import Pagination from '@/components/ui/Pagination' import PostListItem from '@/components/post/PostListItem' import PostListItemSkeleton from '@/components/post/PostListItemSkeleton' +import PostsSidebar from '@/components/post/sidebar/PostsSidebar' import usePageMeta from '@/hooks/usePageMeta' import { getPosts } from '@/services/postApi' import { ROUTES } from '@/constants/routes' @@ -79,55 +80,60 @@ function PostsPage() { return (
+
+
+ +
+
+

커뮤니티

+

자유 · 정보 · 후기를 나누는 Jpop 팬 공간이에요

+
+ +
+ +
+ {CATEGORY_FILTERS.map((c) => ( + + ))} +
+ + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : posts.length > 0 ? ( +
+ {posts.map((post) => ( + + ))} +
+ ) : ( + + )} + + {!isLoading && totalPages > 1 && ( + + )} -
-
-

커뮤니티

-

자유 · 정보 · 후기를 나누는 Jpop 팬 공간이에요

- +
- -
- {CATEGORY_FILTERS.map((c) => ( - - ))} -
- - {isLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) : posts.length > 0 ? ( -
- {posts.map((post) => ( - - ))} -
- ) : ( - - )} - - {!isLoading && totalPages > 1 && ( - - )} -
) diff --git a/src/pages/PostsPage.module.css b/src/pages/PostsPage.module.css index b3f543e..f9478e0 100644 --- a/src/pages/PostsPage.module.css +++ b/src/pages/PostsPage.module.css @@ -4,10 +4,22 @@ } .inner { - max-width: 840px; + max-width: 1152px; margin: 0 auto; } +.layout { + display: flex; + align-items: flex-start; + gap: 2rem; +} + +.main { + flex: 1; + min-width: 0; + max-width: 840px; +} + /* ── 페이지 헤더 ── */ .pageHeader { display: flex; @@ -90,6 +102,12 @@ gap: 0.75rem; } +@media (max-width: 1279px) { + .inner { + max-width: 840px; + } +} + @media (max-width: 767px) { .pageHeader { flex-direction: column; From f842c069f0a6a17ba6ba4c2e9ef1f6f23f24c05c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:41:36 +0900 Subject: [PATCH 25/50] =?UTF-8?q?[feat]=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=EC=97=90=20=EC=9D=B4=EB=B2=88=20=EC=A3=BC=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=EA=B8=80=20=EC=9C=84=EC=A0=AF=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추천수 상위 5개 게시글을 보여주는 위젯. BE에 GET /api/posts/popular 엔드포인트를 요청해둔 상태라, 완료 전까지는 인라인 mock 데이터로 UI를 붙여두고 연동 시 교체할 예정. Co-Authored-By: Claude Sonnet 5 --- .../post/sidebar/PopularPostsWidget.jsx | 32 ++++++++++ .../sidebar/PopularPostsWidget.module.css | 61 +++++++++++++++++++ src/components/post/sidebar/PostsSidebar.jsx | 2 + 3 files changed, 95 insertions(+) create mode 100644 src/components/post/sidebar/PopularPostsWidget.jsx create mode 100644 src/components/post/sidebar/PopularPostsWidget.module.css diff --git a/src/components/post/sidebar/PopularPostsWidget.jsx b/src/components/post/sidebar/PopularPostsWidget.jsx new file mode 100644 index 0000000..30980ef --- /dev/null +++ b/src/components/post/sidebar/PopularPostsWidget.jsx @@ -0,0 +1,32 @@ +import { Link } from 'react-router-dom' + +import { ROUTES } from '@/constants/routes' +import styles from './PopularPostsWidget.module.css' + +// TODO: API 연동 후 제거 — BE GET /api/posts/popular?days=7&limit=5 완료되면 useQuery로 교체 +const MOCK_POPULAR_POSTS = [ + { id: 1, title: '요네즈 켄시 내한 첫 공연, 앙코르만 3번 (스포有)', recommendCount: 128 }, + { id: 4, title: 'LOST CORNER 발매 기념 팬미팅 후기 + 사인회 꿀팁', recommendCount: 95 }, + { id: 2, title: '1층 3층 좌석에서 시야 방해 있었다는 제보 모음', recommendCount: 76 }, + { id: 5, title: '굿즈 판매 부스 위치 정리 (예술의전당 기준)', recommendCount: 58 }, + { id: 3, title: '다들 응모 결과 나왔나요 ㅠㅠ', recommendCount: 41 }, +] + +function PopularPostsWidget() { + return ( +
+

이번 주 인기글

+
+ {MOCK_POPULAR_POSTS.map((post, i) => ( + + {i + 1} + {post.title} + 추천 {post.recommendCount.toLocaleString()} + + ))} +
+
+ ) +} + +export default PopularPostsWidget diff --git a/src/components/post/sidebar/PopularPostsWidget.module.css b/src/components/post/sidebar/PopularPostsWidget.module.css new file mode 100644 index 0000000..97649cf --- /dev/null +++ b/src/components/post/sidebar/PopularPostsWidget.module.css @@ -0,0 +1,61 @@ +.widget { + padding: 1rem 1.125rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.widgetTitle { + margin: 0 0 0.625rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.widgetList { + display: flex; + flex-direction: column; +} + +.rankRow { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4375rem 0; + color: inherit; + text-decoration: none; + border-top: 1px solid var(--color-border); +} + +.rankRow:first-child { + border-top: none; +} + +.rankNum { + flex-shrink: 0; + width: 0.8125rem; + font-size: 0.75rem; + font-weight: 800; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.rankTitle { + flex: 1; + min-width: 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rankStat { + flex-shrink: 0; + font-size: 0.6875rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} diff --git a/src/components/post/sidebar/PostsSidebar.jsx b/src/components/post/sidebar/PostsSidebar.jsx index 8ac501b..8a561bb 100644 --- a/src/components/post/sidebar/PostsSidebar.jsx +++ b/src/components/post/sidebar/PostsSidebar.jsx @@ -1,9 +1,11 @@ +import PopularPostsWidget from './PopularPostsWidget' import UpcomingConcertsWidget from './UpcomingConcertsWidget' import styles from './PostsSidebar.module.css' function PostsSidebar() { return ( ) From be0dd05ac1f7b52239132c5f3fc4a2d92ebc569b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:41:49 +0900 Subject: [PATCH 26/50] =?UTF-8?q?[feat]=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=EC=97=90=20=EC=9D=B8=EA=B8=B0=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=B6=94=EA=B0=80=20(mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 최근 많이 태그된 공연·아티스트·음악 엔티티를 빈도순으로 보여주는 위젯. BE에 GET /api/posts/trending-tags 엔드포인트를 요청해둔 상태라, 완료 전까지는 인라인 mock 데이터로 UI를 붙여두고 연동 시 교체할 예정. Co-Authored-By: Claude Sonnet 5 --- src/components/post/sidebar/PostsSidebar.jsx | 2 ++ .../post/sidebar/TrendingTagsWidget.jsx | 28 ++++++++++++++++++ .../sidebar/TrendingTagsWidget.module.css | 29 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 src/components/post/sidebar/TrendingTagsWidget.jsx create mode 100644 src/components/post/sidebar/TrendingTagsWidget.module.css diff --git a/src/components/post/sidebar/PostsSidebar.jsx b/src/components/post/sidebar/PostsSidebar.jsx index 8a561bb..5837783 100644 --- a/src/components/post/sidebar/PostsSidebar.jsx +++ b/src/components/post/sidebar/PostsSidebar.jsx @@ -1,4 +1,5 @@ import PopularPostsWidget from './PopularPostsWidget' +import TrendingTagsWidget from './TrendingTagsWidget' import UpcomingConcertsWidget from './UpcomingConcertsWidget' import styles from './PostsSidebar.module.css' @@ -6,6 +7,7 @@ function PostsSidebar() { return ( ) diff --git a/src/components/post/sidebar/TrendingTagsWidget.jsx b/src/components/post/sidebar/TrendingTagsWidget.jsx new file mode 100644 index 0000000..f89eda6 --- /dev/null +++ b/src/components/post/sidebar/TrendingTagsWidget.jsx @@ -0,0 +1,28 @@ +import { MENTION_TYPE_LABEL } from '@/constants/post' +import styles from './TrendingTagsWidget.module.css' + +// TODO: API 연동 후 제거 — BE GET /api/posts/trending-tags?days=7&limit=10 완료되면 useQuery로 교체 +const MOCK_TRENDING_TAGS = [ + { entityType: 'CONCERT', entityId: 482, title: '요네즈 켄시 KOREA LIVE 2026', count: 12 }, + { entityType: 'ARTIST', entityId: 101, title: '요네즈 켄시', count: 9 }, + { entityType: 'RELEASE', entityId: 55, title: 'LOST CORNER', count: 7 }, + { entityType: 'CONCERT', entityId: 490, title: '오피셜히게단디즘 단독공연', count: 4 }, + { entityType: 'ARTIST', entityId: 118, title: '아이묭', count: 3 }, +] + +function TrendingTagsWidget() { + return ( +
+

지금 많이 언급된 태그

+
+ {MOCK_TRENDING_TAGS.map((tag) => ( + + {MENTION_TYPE_LABEL[tag.entityType]} · {tag.title} · {tag.count} + + ))} +
+
+ ) +} + +export default TrendingTagsWidget diff --git a/src/components/post/sidebar/TrendingTagsWidget.module.css b/src/components/post/sidebar/TrendingTagsWidget.module.css new file mode 100644 index 0000000..a3f50e3 --- /dev/null +++ b/src/components/post/sidebar/TrendingTagsWidget.module.css @@ -0,0 +1,29 @@ +.widget { + padding: 1rem 1.125rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.widgetTitle { + margin: 0 0 0.625rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.tagCloud { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.tagCloudChip { + padding: 0.1875rem 0.5rem; + font-size: 0.6875rem; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} From cebab11149344a0c10a8569be941658b401cb78c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:44:39 +0900 Subject: [PATCH 27/50] =?UTF-8?q?[feat]=20=EC=9D=B4=EB=B2=88=20=EC=A3=BC?= =?UTF-8?q?=20=EC=9D=B8=EA=B8=B0=EA=B8=80=20=EC=9C=84=EC=A0=AF=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BE GET /api/posts/popular 엔드포인트 완료에 따라 mock 데이터를 제거하고 실 API로 교체. Co-Authored-By: Claude Sonnet 5 --- .../post/sidebar/PopularPostsWidget.jsx | 48 ++++++++++++------- .../sidebar/PopularPostsWidget.module.css | 19 ++++++++ src/services/postApi.js | 5 ++ 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/components/post/sidebar/PopularPostsWidget.jsx b/src/components/post/sidebar/PopularPostsWidget.jsx index 30980ef..a3b81a4 100644 --- a/src/components/post/sidebar/PopularPostsWidget.jsx +++ b/src/components/post/sidebar/PopularPostsWidget.jsx @@ -1,30 +1,44 @@ import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' import { ROUTES } from '@/constants/routes' +import { getPopularPosts } from '@/services/postApi' import styles from './PopularPostsWidget.module.css' -// TODO: API 연동 후 제거 — BE GET /api/posts/popular?days=7&limit=5 완료되면 useQuery로 교체 -const MOCK_POPULAR_POSTS = [ - { id: 1, title: '요네즈 켄시 내한 첫 공연, 앙코르만 3번 (스포有)', recommendCount: 128 }, - { id: 4, title: 'LOST CORNER 발매 기념 팬미팅 후기 + 사인회 꿀팁', recommendCount: 95 }, - { id: 2, title: '1층 3층 좌석에서 시야 방해 있었다는 제보 모음', recommendCount: 76 }, - { id: 5, title: '굿즈 판매 부스 위치 정리 (예술의전당 기준)', recommendCount: 58 }, - { id: 3, title: '다들 응모 결과 나왔나요 ㅠㅠ', recommendCount: 41 }, -] +const POPULAR_DAYS = 7 +const POPULAR_LIMIT = 5 function PopularPostsWidget() { + const { data, isLoading } = useQuery({ + queryKey: ['popular-posts-sidebar'], + queryFn: () => getPopularPosts({ days: POPULAR_DAYS, limit: POPULAR_LIMIT }), + staleTime: 5 * 60 * 1000, + }) + + const posts = data ?? [] + + if (!isLoading && posts.length === 0) return null + return (

이번 주 인기글

-
- {MOCK_POPULAR_POSTS.map((post, i) => ( - - {i + 1} - {post.title} - 추천 {post.recommendCount.toLocaleString()} - - ))} -
+ {isLoading ? ( +
+ {Array.from({ length: POPULAR_LIMIT }).map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {posts.map((post, i) => ( + + {i + 1} + {post.title} + 추천 {post.recommendCount.toLocaleString()} + + ))} +
+ )}
) } diff --git a/src/components/post/sidebar/PopularPostsWidget.module.css b/src/components/post/sidebar/PopularPostsWidget.module.css index 97649cf..31ab61d 100644 --- a/src/components/post/sidebar/PopularPostsWidget.module.css +++ b/src/components/post/sidebar/PopularPostsWidget.module.css @@ -59,3 +59,22 @@ color: var(--color-text-muted); font-variant-numeric: tabular-nums; } + +.rankSkeleton { + height: 1.375rem; + margin: 0.4375rem 0; + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + var(--color-surface-hover) 25%, + var(--color-border) 50%, + var(--color-surface-hover) 75% + ); + background-size: 800px 100%; + animation: shimmer 1.4s infinite linear; +} + +@keyframes shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} diff --git a/src/services/postApi.js b/src/services/postApi.js index 741ccec..7d9522d 100644 --- a/src/services/postApi.js +++ b/src/services/postApi.js @@ -10,6 +10,11 @@ export async function getPost(id) { return data } +export async function getPopularPosts(params) { + const { data } = await api.get('/posts/popular', { params }) + return data +} + export async function createPost(payload) { const { data } = await api.post('/posts', payload) return data From ee865f58f6438d9dac59b4a7aa391be1e52ae058 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:44:51 +0900 Subject: [PATCH 28/50] =?UTF-8?q?[feat]=20=EC=9D=B8=EA=B8=B0=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=9C=84=EC=A0=AF=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BE GET /api/posts/trending-tags 엔드포인트 완료에 따라 mock 데이터를 제거하고 실 API로 교체. 긴 태그 제목이 두 줄로 밀리며 칩 높이가 어긋나는 문제를 막기 위해 말줄임 처리를 추가. Co-Authored-By: Claude Sonnet 5 --- .../post/sidebar/TrendingTagsWidget.jsx | 45 ++++++++++++------- .../sidebar/TrendingTagsWidget.module.css | 23 ++++++++++ src/services/postApi.js | 5 +++ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/components/post/sidebar/TrendingTagsWidget.jsx b/src/components/post/sidebar/TrendingTagsWidget.jsx index f89eda6..9125a82 100644 --- a/src/components/post/sidebar/TrendingTagsWidget.jsx +++ b/src/components/post/sidebar/TrendingTagsWidget.jsx @@ -1,26 +1,41 @@ +import { useQuery } from '@tanstack/react-query' + import { MENTION_TYPE_LABEL } from '@/constants/post' +import { getTrendingTags } from '@/services/postApi' import styles from './TrendingTagsWidget.module.css' -// TODO: API 연동 후 제거 — BE GET /api/posts/trending-tags?days=7&limit=10 완료되면 useQuery로 교체 -const MOCK_TRENDING_TAGS = [ - { entityType: 'CONCERT', entityId: 482, title: '요네즈 켄시 KOREA LIVE 2026', count: 12 }, - { entityType: 'ARTIST', entityId: 101, title: '요네즈 켄시', count: 9 }, - { entityType: 'RELEASE', entityId: 55, title: 'LOST CORNER', count: 7 }, - { entityType: 'CONCERT', entityId: 490, title: '오피셜히게단디즘 단독공연', count: 4 }, - { entityType: 'ARTIST', entityId: 118, title: '아이묭', count: 3 }, -] +const TRENDING_DAYS = 7 +const TRENDING_LIMIT = 10 function TrendingTagsWidget() { + const { data, isLoading } = useQuery({ + queryKey: ['trending-tags-sidebar'], + queryFn: () => getTrendingTags({ days: TRENDING_DAYS, limit: TRENDING_LIMIT }), + staleTime: 5 * 60 * 1000, + }) + + const tags = data ?? [] + + if (!isLoading && tags.length === 0) return null + return (

지금 많이 언급된 태그

-
- {MOCK_TRENDING_TAGS.map((tag) => ( - - {MENTION_TYPE_LABEL[tag.entityType]} · {tag.title} · {tag.count} - - ))} -
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {tags.map((tag) => ( + + {MENTION_TYPE_LABEL[tag.entityType]} · {tag.title} · {tag.count} + + ))} +
+ )}
) } diff --git a/src/components/post/sidebar/TrendingTagsWidget.module.css b/src/components/post/sidebar/TrendingTagsWidget.module.css index a3f50e3..e72a247 100644 --- a/src/components/post/sidebar/TrendingTagsWidget.module.css +++ b/src/components/post/sidebar/TrendingTagsWidget.module.css @@ -21,9 +21,32 @@ } .tagCloudChip { + max-width: 100%; padding: 0.1875rem 0.5rem; font-size: 0.6875rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: var(--radius-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tagSkeleton { + width: 5.5rem; + height: 1.375rem; + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + var(--color-surface-hover) 25%, + var(--color-border) 50%, + var(--color-surface-hover) 75% + ); + background-size: 800px 100%; + animation: shimmer 1.4s infinite linear; +} + +@keyframes shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } } diff --git a/src/services/postApi.js b/src/services/postApi.js index 7d9522d..1b88bed 100644 --- a/src/services/postApi.js +++ b/src/services/postApi.js @@ -15,6 +15,11 @@ export async function getPopularPosts(params) { return data } +export async function getTrendingTags(params) { + const { data } = await api.get('/posts/trending-tags', { params }) + return data +} + export async function createPost(payload) { const { data } = await api.post('/posts', payload) return data From 6410a2f1e934adc2430a7b6721a908b0a1fc60ec Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 17:05:20 +0900 Subject: [PATCH 29/50] =?UTF-8?q?[ui]=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=ED=83=80=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=99=80=20=ED=86=B5=EC=9D=BC,=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=EC=9E=90=EB=AA=85=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이지 헤더 배경/테두리 제거, 타이틀 크기·컨테이너 max-width를 공연·음악·아티스트 페이지와 통일 - 카테고리 필터와 글쓰기 버튼을 같은 행에 배치, 사이드바 위젯이 첫 게시글과 상단 정렬되도록 레이아웃 재구성 - 모바일에서 숨겨져 있던 게시글 작성자 닉네임 노출 복원 Co-Authored-By: Claude Sonnet 5 --- src/components/post/PostListItem.module.css | 9 +--- src/pages/PostsPage.jsx | 49 +++++++++++---------- src/pages/PostsPage.module.css | 34 +++++++------- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/components/post/PostListItem.module.css b/src/components/post/PostListItem.module.css index be2616b..4b59ecb 100644 --- a/src/components/post/PostListItem.module.css +++ b/src/components/post/PostListItem.module.css @@ -75,6 +75,7 @@ .meta { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.375rem; flex-shrink: 0; font-size: 0.8125rem; @@ -106,12 +107,4 @@ .tags .dot { display: none; } - - .author { - display: none; - } - - .dot:first-of-type { - display: none; - } } diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index cb23007..f8e7d6e 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -80,33 +80,34 @@ function PostsPage() { return (
+ +
+

커뮤니티

+

자유 · 정보 · 후기를 나누는 Jpop 팬 공간이에요

+
+ +
+
+ {CATEGORY_FILTERS.map((c) => ( + + ))} +
+ +
+
-
-
-

커뮤니티

-

자유 · 정보 · 후기를 나누는 Jpop 팬 공간이에요

-
- -
- -
- {CATEGORY_FILTERS.map((c) => ( - - ))} -
- {isLoading ? (
{Array.from({ length: 8 }).map((_, i) => ( diff --git a/src/pages/PostsPage.module.css b/src/pages/PostsPage.module.css index f9478e0..c35bba8 100644 --- a/src/pages/PostsPage.module.css +++ b/src/pages/PostsPage.module.css @@ -4,7 +4,7 @@ } .inner { - max-width: 1152px; + max-width: var(--max-width); margin: 0 auto; } @@ -17,25 +17,16 @@ .main { flex: 1; min-width: 0; - max-width: 840px; } /* ── 페이지 헤더 ── */ .pageHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - padding: 1.25rem 1.5rem; - margin-bottom: 1.5rem; - background: var(--color-accent-dim); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); + margin-bottom: 1.75rem; } .pageTitle { margin: 0 0 0.2rem; - font-size: 1.5rem; + font-size: 1.875rem; font-weight: 700; color: var(--color-text); letter-spacing: -0.02em; @@ -47,18 +38,27 @@ color: var(--color-text-muted); } +/* ── 툴바 (카테고리 필터 + 글쓰기) ── */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + .writeBtn { flex-shrink: 0; } -/* ── 카테고리 필터 ── */ .filterBar { display: flex; gap: 0.5rem; overflow-x: auto; padding-bottom: 0.25rem; - margin-bottom: 0.5rem; scrollbar-width: none; + flex: 1; + min-width: 0; } .filterBar::-webkit-scrollbar { @@ -109,10 +109,10 @@ } @media (max-width: 767px) { - .pageHeader { + .toolbar { flex-direction: column; - align-items: flex-start; - gap: 1rem; + align-items: stretch; + gap: 0.75rem; } .writeBtn { From 9b66795016ac2ad79167ff931fc39c8e9127c4bf Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 17:13:38 +0900 Subject: [PATCH 30/50] =?UTF-8?q?[ui]=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B8=80=EC=93=B0=EA=B8=B0=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=9D=84=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=8F=AD=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - layout을 grid(1fr 15.5rem)로 전환해 툴바(카테고리 필터+글쓰기)가 사이드바 위가 아닌 게시글 목록 칸 폭에만 걸리도록 수정 - 사이드바는 기존과 동일하게 첫 게시글과 상단 정렬 유지 Co-Authored-By: Claude Sonnet 5 --- src/pages/PostsPage.jsx | 41 ++++++++++++++++++---------------- src/pages/PostsPage.module.css | 30 ++++++++++++++++++++----- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index f8e7d6e..fb0338f 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -86,26 +86,26 @@ function PostsPage() {

자유 · 정보 · 후기를 나누는 Jpop 팬 공간이에요

-
-
- {CATEGORY_FILTERS.map((c) => ( - - ))} +
+
+
+ {CATEGORY_FILTERS.map((c) => ( + + ))} +
+
- -
-
{isLoading ? ( @@ -133,7 +133,10 @@ function PostsPage() { )}
- + +
+ +
diff --git a/src/pages/PostsPage.module.css b/src/pages/PostsPage.module.css index c35bba8..46dcc81 100644 --- a/src/pages/PostsPage.module.css +++ b/src/pages/PostsPage.module.css @@ -8,17 +8,26 @@ margin: 0 auto; } +/* 2열: 본문(툴바/목록) | 사이드바(15.5rem, PostsSidebar.module.css와 폭 일치) */ .layout { - display: flex; - align-items: flex-start; - gap: 2rem; + display: grid; + grid-template-columns: 1fr 15.5rem; + column-gap: 2rem; + row-gap: 1.5rem; + align-items: start; } .main { - flex: 1; + grid-column: 1; + grid-row: 2; min-width: 0; } +.sidebarCell { + grid-column: 2; + grid-row: 2; +} + /* ── 페이지 헤더 ── */ .pageHeader { margin-bottom: 1.75rem; @@ -38,13 +47,14 @@ color: var(--color-text-muted); } -/* ── 툴바 (카테고리 필터 + 글쓰기) ── */ +/* ── 툴바 (카테고리 필터 + 글쓰기, 목록 칸과 동일한 폭) ── */ .toolbar { + grid-column: 1; + grid-row: 1; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; - margin-bottom: 1.5rem; } .writeBtn { @@ -106,6 +116,14 @@ .inner { max-width: 840px; } + + .layout { + grid-template-columns: 1fr; + } + + .sidebarCell { + display: none; + } } @media (max-width: 767px) { From 2cf90df43628813f9695b41eba5d47eb82d3526d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 17:33:39 +0900 Subject: [PATCH 31/50] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EA=B8=80=EC=9E=90=EC=88=98=C2=B7=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드가 본문 10,000자, 태그 10개로 상한을 도입해 프론트에도 동일 기준을 맞춘다. Tiptap CharacterCount 확장으로 본문 입력을 서버와 동일한 plain text 기준으로 제한하고, 글자수·태그 개수 실시간 카운터와 제출 시 검증을 추가해 서버 반려 전에 미리 안내한다. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 14 ++++++++ package.json | 1 + src/components/post/editor/PostEditor.jsx | 11 ++++-- src/constants/post.js | 3 ++ src/pages/PostWritePage.jsx | 43 +++++++++++++++++++++-- src/pages/PostWritePage.module.css | 23 ++++++++++++ 6 files changed, 90 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 06455e8..080d950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.96.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-character-count": "^3.31.3", "@tiptap/extension-color": "^3.31.3", "@tiptap/extension-placeholder": "^3.31.3", "@tiptap/extension-text-style": "^3.31.3", @@ -1048,6 +1049,19 @@ "@tiptap/extension-list": "3.31.3" } }, + "node_modules/@tiptap/extension-character-count": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.31.3.tgz", + "integrity": "sha512-PVYEnEUqzjwSMfhHW+TzgoNB4UqYQQLKsY97CBsfuRswEfsqeViN3kMEuJEMMzN2fFnkB0hkRTpGlnMYcbd6Ag==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, "node_modules/@tiptap/extension-code": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", diff --git a/package.json b/package.json index e86fea9..547c5dd 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@tanstack/react-query": "^5.96.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-character-count": "^3.31.3", "@tiptap/extension-color": "^3.31.3", "@tiptap/extension-placeholder": "^3.31.3", "@tiptap/extension-text-style": "^3.31.3", diff --git a/src/components/post/editor/PostEditor.jsx b/src/components/post/editor/PostEditor.jsx index 9347e0b..473dce9 100644 --- a/src/components/post/editor/PostEditor.jsx +++ b/src/components/post/editor/PostEditor.jsx @@ -3,13 +3,15 @@ import StarterKit from '@tiptap/starter-kit' import Color from '@tiptap/extension-color' import { TextStyle } from '@tiptap/extension-text-style' import Placeholder from '@tiptap/extension-placeholder' +import CharacterCount from '@tiptap/extension-character-count' import { EntityMentionCard, EntityMentionChip } from './entityMentionExtensions' import EditorBubbleMenu from './EditorBubbleMenu' import SlashCommand from './slashCommand' +import { POST_CONTENT_MAX_LENGTH } from '@/constants/post' import styles from './PostEditor.module.css' -function PostEditor({ content, onChange }) { +function PostEditor({ content, onChange, onCharacterCountChange }) { const editor = useEditor({ extensions: [ StarterKit.configure({ link: { openOnClick: false } }), @@ -24,9 +26,14 @@ function PostEditor({ content, onChange }) { ? "'/' 를 입력해 서식을 지정하거나 공연·아티스트·음악을 태그해보세요" : '', }), + CharacterCount.configure({ limit: POST_CONTENT_MAX_LENGTH }), ], content, - onUpdate: ({ editor: e }) => onChange?.(e.getJSON()), + onCreate: ({ editor: e }) => onCharacterCountChange?.(e.storage.characterCount.characters()), + onUpdate: ({ editor: e }) => { + onChange?.(e.getJSON()) + onCharacterCountChange?.(e.storage.characterCount.characters()) + }, editorProps: { attributes: { class: styles.prose, diff --git a/src/constants/post.js b/src/constants/post.js index 707499a..7378740 100644 --- a/src/constants/post.js +++ b/src/constants/post.js @@ -1,3 +1,6 @@ +export const POST_CONTENT_MAX_LENGTH = 10000 +export const POST_ENTITY_TAG_MAX_COUNT = 10 + export const POST_CATEGORY_LABEL = { FREE: '자유', INFO: '정보', diff --git a/src/pages/PostWritePage.jsx b/src/pages/PostWritePage.jsx index 99902e1..a38b6b6 100644 --- a/src/pages/PostWritePage.jsx +++ b/src/pages/PostWritePage.jsx @@ -9,7 +9,12 @@ import EmptyState from '@/components/ui/EmptyState' import usePageMeta from '@/hooks/usePageMeta' import { createPost, getPost, updatePost } from '@/services/postApi' import { ROUTES } from '@/constants/routes' -import { MENTION_TYPE_LABEL, POST_CATEGORY_LABEL } from '@/constants/post' +import { + MENTION_TYPE_LABEL, + POST_CATEGORY_LABEL, + POST_CONTENT_MAX_LENGTH, + POST_ENTITY_TAG_MAX_COUNT, +} from '@/constants/post' import styles from './PostWritePage.module.css' const CATEGORIES = ['FREE', 'INFO', 'REVIEW'] @@ -23,6 +28,7 @@ function PostWritePage() { const [title, setTitle] = useState('') const [category, setCategory] = useState('FREE') const [contentJson, setContentJson] = useState(null) + const [contentLength, setContentLength] = useState(0) const [error, setError] = useState(null) const [formReady, setFormReady] = useState(!isEditMode) @@ -82,6 +88,14 @@ function PostWritePage() { setError('본문을 입력해주세요.') return } + if (contentLength > POST_CONTENT_MAX_LENGTH) { + setError(`본문은 ${POST_CONTENT_MAX_LENGTH.toLocaleString()}자를 초과할 수 없습니다.`) + return + } + if (entityTags.length > POST_ENTITY_TAG_MAX_COUNT) { + setError(`태그는 최대 ${POST_ENTITY_TAG_MAX_COUNT}개까지 추가할 수 있습니다.`) + return + } setError(null) const payload = { @@ -151,10 +165,33 @@ function PostWritePage() { maxLength={100} /> - + + +
POST_CONTENT_MAX_LENGTH ? styles.contentCountOver : styles.contentCount + } + > + {contentLength.toLocaleString()} / {POST_CONTENT_MAX_LENGTH.toLocaleString()}자 +
- 태그된 항목 + + 태그된 항목{' '} + POST_ENTITY_TAG_MAX_COUNT + ? styles.tagCountOver + : styles.tagCount + } + > + ({entityTags.length}/{POST_ENTITY_TAG_MAX_COUNT}) + + {entityTags.length > 0 ? (
{entityTags.map((tag) => ( diff --git a/src/pages/PostWritePage.module.css b/src/pages/PostWritePage.module.css index 598f0d9..3ebd305 100644 --- a/src/pages/PostWritePage.module.css +++ b/src/pages/PostWritePage.module.css @@ -126,6 +126,20 @@ font-weight: 500; } +/* ── 본문 글자수 ── */ +.contentCount { + margin-top: 0.5rem; + text-align: right; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-muted); +} + +.contentCountOver { + composes: contentCount; + color: var(--color-destructive); +} + /* ── 태그 미리보기 ── */ .tagRow { display: flex; @@ -141,6 +155,15 @@ color: var(--color-text-muted); } +.tagCount { + font-weight: 500; +} + +.tagCountOver { + composes: tagCount; + color: var(--color-destructive); +} + .tagRowEmpty { font-size: 0.8125rem; color: var(--color-text-muted); From 976f8f2f637a5acff94c9d274b41eedc1091a342 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:25:02 +0900 Subject: [PATCH 32/50] =?UTF-8?q?[ui]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=B9=B4=EB=93=9C=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본문 폭을 760px에서 840px로 확장하고 surface 카드로 감싸 배경과 분리했다. 목록 페이지(1280px)와의 폭 격차를 줄이면서도 프로즈 영역에 별도 max-width(42rem)를 둬 읽기 줄 길이는 그대로 유지했다. Co-Authored-By: Claude Sonnet 5 --- .../post/PostContentView.module.css | 1 + src/pages/PostDetailPage.jsx | 94 ++++++++++--------- src/pages/PostDetailPage.module.css | 13 ++- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/src/components/post/PostContentView.module.css b/src/components/post/PostContentView.module.css index 583c555..edb9d05 100644 --- a/src/components/post/PostContentView.module.css +++ b/src/components/post/PostContentView.module.css @@ -1,4 +1,5 @@ .prose { + max-width: 42rem; font-size: 0.9375rem; line-height: 1.8; color: var(--color-text); diff --git a/src/pages/PostDetailPage.jsx b/src/pages/PostDetailPage.jsx index ff50780..8f912db 100644 --- a/src/pages/PostDetailPage.jsx +++ b/src/pages/PostDetailPage.jsx @@ -67,9 +67,11 @@ function PostDetailPage() {
-
-
-
+
+
+
+
+
) @@ -109,48 +111,50 @@ function PostDetailPage() {
-
- - {POST_CATEGORY_LABEL[post.category]} - - {formatDateTime(post.createdAt)} - - 조회 {post.viewCount.toLocaleString()} -
- -

{post.title}

- -
- {post.authorNickname ?? '탈퇴 회원'} - {post.isAuthor && ( -
- - -
- )} -
- - - -
- +
+
+ + {POST_CATEGORY_LABEL[post.category]} + + {formatDateTime(post.createdAt)} + + 조회 {post.viewCount.toLocaleString()} +
+ +

{post.title}

+ +
+ {post.authorNickname ?? '탈퇴 회원'} + {post.isAuthor && ( +
+ + +
+ )} +
+ + + +
+ +
diff --git a/src/pages/PostDetailPage.module.css b/src/pages/PostDetailPage.module.css index 594eada..db48bd6 100644 --- a/src/pages/PostDetailPage.module.css +++ b/src/pages/PostDetailPage.module.css @@ -4,10 +4,17 @@ } .inner { - max-width: 760px; + max-width: 840px; margin: 0 auto; } +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + padding: 2.25rem 2.75rem 2.5rem; +} + @keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } @@ -30,7 +37,7 @@ .skeletonMeta { width: 160px; height: 20px; - margin: 1.25rem 0 1rem; + margin: 0 0 1rem; } .skeletonTitle { @@ -48,7 +55,7 @@ display: flex; align-items: center; gap: 0.5rem; - margin: 1.25rem 0 0.75rem; + margin: 0 0 0.75rem; } .badge { From e5b9bd3683c0f0668d5256017492c87b12177d1b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:25:15 +0900 Subject: [PATCH 33/50] =?UTF-8?q?[ui]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=98=95=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArtistDetailPage·ConcertDetailPage와 동일한 컨벤션으로 카드 테두리·radius는 유지한 채 패딩과 제목 크기만 축소했다. 기존에는 @media 규칙 자체가 없어 넓은 화면 패딩이 모바일까지 그대로 적용되던 문제를 해소했다. Co-Authored-By: Claude Sonnet 5 --- src/pages/PostDetailPage.module.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/pages/PostDetailPage.module.css b/src/pages/PostDetailPage.module.css index db48bd6..7b00ed5 100644 --- a/src/pages/PostDetailPage.module.css +++ b/src/pages/PostDetailPage.module.css @@ -159,3 +159,17 @@ border-color: var(--color-accent); color: var(--color-on-accent); } + +@media (max-width: 767px) { + .page { + padding: 1.5rem 1rem 3rem; + } + + .card { + padding: 1.5rem 1.25rem 2rem; + } + + .title { + font-size: 1.375rem; + } +} From b7a02b21900a5e8ebc3cf3c34951988c20b7abe4 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:30:56 +0900 Subject: [PATCH 34/50] =?UTF-8?q?[ui]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20UI=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=20(=EB=AA=A9=EB=8D=B0=EC=9D=B4=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommentSection·CommentItem·CommentComposer 신규 추가. 목록 조회·작성· 좋아요·답글은 로컬 state에서만 동작하며 실제 API 호출은 없다 (POST-07 확정 후 GET/POST /api/posts/{id}/comments 등으로 교체 예정). 비로그인 시 기존 loginModalStore 패턴을 그대로 재사용해 로그인 모달을 띄운다. TODO: API 연동 후 MOCK_COMMENTS 제거 Co-Authored-By: Claude Sonnet 5 --- .../post/comment/CommentComposer.jsx | 57 +++++++ .../post/comment/CommentComposer.module.css | 84 +++++++++++ src/components/post/comment/CommentItem.jsx | 108 ++++++++++++++ .../post/comment/CommentItem.module.css | 108 ++++++++++++++ .../post/comment/CommentSection.jsx | 140 ++++++++++++++++++ .../post/comment/CommentSection.module.css | 24 +++ src/pages/PostDetailPage.jsx | 5 + src/pages/PostDetailPage.module.css | 6 + 8 files changed, 532 insertions(+) create mode 100644 src/components/post/comment/CommentComposer.jsx create mode 100644 src/components/post/comment/CommentComposer.module.css create mode 100644 src/components/post/comment/CommentItem.jsx create mode 100644 src/components/post/comment/CommentItem.module.css create mode 100644 src/components/post/comment/CommentSection.jsx create mode 100644 src/components/post/comment/CommentSection.module.css diff --git a/src/components/post/comment/CommentComposer.jsx b/src/components/post/comment/CommentComposer.jsx new file mode 100644 index 0000000..86877ee --- /dev/null +++ b/src/components/post/comment/CommentComposer.jsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import styles from './CommentComposer.module.css' + +function CommentComposer({ + isLoggedIn, + onRequireLogin, + onSubmit, + placeholder = '댓글을 남겨보세요', + compact = false, + onCancel, +}) { + const [value, setValue] = useState('') + + function handleFocus() { + if (!isLoggedIn) onRequireLogin() + } + + function handleSubmit() { + const trimmed = value.trim() + if (!trimmed) return + onSubmit(trimmed) + setValue('') + } + + return ( +
+
+