게시물 추천과 관리자 목록 필터 정리

This commit is contained in:
2026-05-15 11:49:12 +09:00
parent 59a50a0c97
commit 6d6e189b63
20 changed files with 258 additions and 46 deletions

View File

@@ -105,6 +105,7 @@ const form = reactive({
excerpt: props.initialPost.excerpt || '',
content: normalizeMarkdownContent(props.initialPost.content),
featuredImage: props.initialPost.featuredImage || '',
isFeatured: Boolean(props.initialPost.isFeatured),
noindex: Boolean(props.initialPost.noindex),
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
@@ -301,6 +302,7 @@ const createPostPayload = () => {
excerpt: form.excerpt.trim(),
content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage.trim() || null,
isFeatured: form.isFeatured,
seoTitle: form.title.trim(),
seoDescription: form.excerpt.trim(),
canonicalUrl: '',
@@ -330,6 +332,7 @@ const createAutosavePayload = () => ({
excerpt: form.excerpt,
content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage,
isFeatured: form.isFeatured,
noindex: form.noindex,
status: form.status,
publishedAt: form.publishedAt,
@@ -347,7 +350,8 @@ const isEmptyAutosavePayload = (payload) => ![
payload.excerpt,
payload.content,
payload.featuredImage,
payload.tagsText
payload.tagsText,
payload.isFeatured ? 'featured' : ''
].some((value) => String(value || '').trim())
/**
@@ -1026,6 +1030,29 @@ defineExpose({
</label>
</div>
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-post-form__featured-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__featured-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3.5l2.7 5.47 6.04.88-4.37 4.26 1.03 6.01L12 17.28l-5.4 2.84 1.03-6.01-4.37-4.26 6.04-.88L12 3.5z" />
</svg>
</span>
<span class="admin-post-form__featured-toggle-label font-bold text-[#15171a]">
추천
</span>
</span>
<span class="admin-post-form__featured-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.isFeatured"
class="peer sr-only"
type="checkbox"
aria-label="추천 글로 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<div class="admin-post-form__search-visibility grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
<div>
<h2 class="admin-post-form__section-title text-sm font-semibold text-ink">

View File

@@ -19,6 +19,9 @@ const { data: navigation } = await useFetch('/api/navigation', {
})
})
/** 저자 영역 공개 여부 */
const showAuthorSection = false
const STORAGE_KEY = 'sori-primary-nav-expanded'
/**
@@ -173,7 +176,7 @@ onMounted(() => {
</div>
</div>
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
<div v-if="showAuthorSection" class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
<span>Authors</span>
<span></span>

View File

@@ -17,6 +17,9 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
copyrightText: '©2026 sori.studio'
})
})
/** 소개 영역 공개 여부 */
const showAboutSection = false
</script>
<template>
@@ -176,7 +179,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</div>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div v-if="showAboutSection" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<p class="right-sidebar__about text-sm leading-6 site-muted">
{{ siteSettings.description }}
</p>

View File

@@ -8,8 +8,7 @@ const member = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
logoUrl: ''
title: 'sori.studio'
})
})
@@ -186,12 +185,6 @@ onBeforeUnmount(() => {
</svg>
</span>
</button>
<img
v-if="siteSettings.logoUrl"
class="site-header__brand-logo h-7 w-7 shrink-0 rounded-md object-cover"
:src="siteSettings.logoUrl"
:alt="siteSettings.title"
>
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
</NuxtLink>
</div>

View File

@@ -0,0 +1,5 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS posts_is_featured_status_published_at_idx
ON posts (is_featured, status, published_at DESC);

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.1.9
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
- 글쓰기 사이드바에 추천 글 토글을 추가하고, 홈 Featured와 번개 표시는 실제 추천 글만 기준으로 표시.
- 공개 헤더는 텍스트 사이트 이름만 사용하고, 사이드바의 Authors/About 영역은 숨김 처리.
## v1.1.8
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.

View File

@@ -136,6 +136,9 @@ cp .env.example .env.production
# Docker 빌드 및 실행
docker compose --env-file .env.production up -d --build
# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글 컬럼 마이그레이션 적용
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql
```
### Docker 네트워크 충돌 대응

View File

@@ -1,5 +1,15 @@
# 의사결정 이력
## 2026-05-15 v1.1.9
### 추천 글을 저장 필드로 분리
홈 Featured와 목록의 번개 표시는 최신 글 여부가 아니라 운영자가 명시한 추천 상태여야 한다. 기존처럼 첫 번째 글을 추천처럼 보이게 하면 추천 의도와 최신순 정렬이 섞이므로, 게시물에 `is_featured` 필드를 추가하고 글쓰기 사이드바 토글로 관리하도록 했다. 추천 글이 없으면 홈 Featured 영역도 숨겨 빈 운영 상태에서 불필요한 섹션이 보이지 않게 한다.
### 관리자 글 목록 필터를 클라이언트 우선으로 도입
현재 관리자 글 목록은 전체 글을 한 번에 조회하는 구조라 상태·태그·정렬 필터를 클라이언트에서 먼저 적용해 변경 범위를 줄였다. 목록 규모가 커지면 같은 필터 기준을 `/admin/api/posts` 쿼리 파라미터로 옮길 수 있도록 상태 키와 태그 필터 계산을 별도 함수로 분리했다.
## 2026-05-15 v1.1.8
### 태그 순서 저장을 드롭 즉시 자동화

View File

@@ -50,11 +50,11 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, About 영역은 비공개, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
@@ -64,7 +64,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 추천 글 토글, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
@@ -107,7 +107,7 @@
|------|------|
| pages/admin/index.vue | 대시보드 |
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
| pages/admin/posts/index.vue | 글 목록, 상태·태그·최신순/오래된순 필터, 예약/초안/발행 텍스트 상태 표시, 제목 옆 댓글 수, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
@@ -128,7 +128,7 @@
| 파일 | 화면 |
|------|------|
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |

View File

@@ -51,6 +51,7 @@
### 홈 Featured (인덱스)
- 가로 카드 트랙은 `overflow-x-auto``snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
@@ -220,6 +221,7 @@ components/content/
| content | Text | 마크다운 콘텐츠 |
| excerpt | String | 요약 |
| featured_image | String nullable | 대표 이미지 |
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
| seo_title | String | SEO 제목 |
| seo_description | String | SEO 설명 |
| canonical_url | String | canonical URL |
@@ -230,6 +232,8 @@ components/content/
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
### Users
| 필드 | 타입 | 설명 |

View File

@@ -1,5 +1,16 @@
# 업데이트 이력
## v1.1.9
- 관리자 글 목록에 상태·태그·최신순/오래된순 필터 추가.
- 관리자 글 목록 상태 표시를 배지에서 단순 텍스트 색상 기준으로 정리하고 제목 옆 댓글 수 표시 추가.
- 게시물 추천 여부(`is_featured`) 저장 필드와 글쓰기 사이드바 추천 토글 추가.
- 홈 Featured 영역을 추천 글이 있을 때만 표시하고, 최신 글의 번개 표시는 실제 추천 글에만 나오도록 수정.
- 공개 목록·상세의 댓글 수 표시를 API 댓글 집계값 기준으로 정리.
- 공개 헤더의 이미지 로고 주석 코드를 제거하고 사이트 이름 텍스트만 표시하도록 정리.
- 왼쪽 사이드바 Authors 영역과 오른쪽 사이드바 About 영역을 비공개 처리.
- 패키지 버전 `1.1.9`로 갱신.
## v1.1.8
- 사이트 로고·파비콘 고유 파일명 접미사를 년월+랜덤 문자열 형식으로 간소화.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.1.8",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.1.8",
"version": "1.1.9",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.1.8",
"version": "1.1.9",
"private": true,
"type": "module",
"imports": {

View File

@@ -5,11 +5,18 @@ definePageMeta({
const deletingId = ref('')
const errorMessage = ref('')
const statusFilter = ref('all')
const tagFilter = ref('all')
const sortOrder = ref('newest')
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
default: () => []
})
const { data: tags } = await useFetch('/admin/api/tags', {
default: () => []
})
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
@@ -36,27 +43,106 @@ const formatDate = (value) => {
const isPublicPost = (post) => post.status === 'published'
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
/**
* 게시물 상태 필터 키 생성
* @param {Object} post - 게시물
* @returns {'published' | 'scheduled' | 'draft' | 'private'} 상태 키
*/
const getPostStatusKey = (post) => {
if (post.status === 'published' && !isPublicPost(post)) {
return 'scheduled'
}
if (post.status === 'published') {
return 'published'
}
if (post.status === 'private') {
return 'private'
}
return 'draft'
}
/**
* 게시물 상태 표시 문자열 생성
* @param {Object} post - 게시물
* @returns {string} 상태 표시 문자열
*/
const getPostStatusLabel = (post) => {
if (post.status === 'published' && !isPublicPost(post)) {
const statusKey = getPostStatusKey(post)
if (statusKey === 'scheduled') {
return '예약'
}
if (post.status === 'published') {
if (statusKey === 'published') {
return '발행'
}
if (post.status === 'private') {
if (statusKey === 'private') {
return '비공개'
}
return '초안'
}
/**
* 게시물 상태 텍스트 클래스 생성
* @param {Object} post - 게시물
* @returns {string} 상태 텍스트 클래스
*/
const getPostStatusClass = (post) => {
const statusKey = getPostStatusKey(post)
if (statusKey === 'scheduled') {
return 'font-bold text-[#30cf43]'
}
if (statusKey === 'draft') {
return 'font-bold text-[#fb2d8d]'
}
if (statusKey === 'published') {
return 'text-[#99A3AD]'
}
return 'font-bold text-[#8e9cac]'
}
/**
* 태그 슬러그의 표시 이름을 조회한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 표시 이름
*/
const getTagName = (slug) => tags.value.find((tag) => tag.slug === slug)?.name || slug
const usedTagSlugs = computed(() => {
const slugs = new Set()
for (const post of posts.value) {
for (const tag of post.tags || []) {
slugs.add(tag)
}
}
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
})
const filteredPosts = computed(() => {
const filtered = posts.value.filter((post) => {
const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value
const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value)
return matchesStatus && matchesTag
})
return [...filtered].sort((a, b) => {
const left = new Date(a.updatedAt || a.createdAt || 0).getTime()
const right = new Date(b.updatedAt || b.createdAt || 0).getTime()
return sortOrder.value === 'oldest' ? left - right : right - left
})
})
/**
* 게시물 삭제
* @param {Object} post - 삭제할 게시물
@@ -99,6 +185,34 @@ const deletePost = async (post) => {
</NuxtLink>
</div>
<div class="admin-posts__filters mt-6 flex flex-wrap items-center gap-2">
<label class="admin-posts__filter">
<span class="sr-only">상태 필터</span>
<select v-model="statusFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="all">전체 상태</option>
<option value="published">발행</option>
<option value="draft">초안</option>
<option value="scheduled">예약</option>
</select>
</label>
<label class="admin-posts__filter">
<span class="sr-only">태그 필터</span>
<select v-model="tagFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="all">전체 태그</option>
<option v-for="tag in usedTagSlugs" :key="tag" :value="tag">
{{ getTagName(tag) }}
</option>
</select>
</label>
<label class="admin-posts__filter">
<span class="sr-only">정렬</span>
<select v-model="sortOrder" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
</select>
</label>
</div>
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
@@ -115,24 +229,24 @@ const deletePost = async (post) => {
</tr>
</thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
<tr v-for="post in posts" :key="post.id" class="admin-posts__row">
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
<td class="admin-posts__cell px-4 py-4">
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
{{ post.title }}
</NuxtLink>
<div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
{{ post.title || '(제목 없음)' }}
</NuxtLink>
<span class="admin-posts__comment-count text-xs font-medium text-muted">
댓글 {{ post.commentCount || 0 }}
</span>
</div>
<p class="admin-posts__slug mt-1 text-xs text-muted">
/post/{{ post.slug }}
</p>
</td>
<td class="admin-posts__cell px-4 py-4">
<span
class="admin-posts__status rounded px-2 py-1 text-xs font-semibold"
:class="{
'bg-green-50 text-green-700': getPostStatusLabel(post) === '발행',
'bg-blue-50 text-blue-700': getPostStatusLabel(post) === '예약',
'bg-[#f5f5f2] text-muted': getPostStatusLabel(post) === '초안',
'bg-red-50 text-red-700': getPostStatusLabel(post) === '비공개'
}"
class="admin-posts__status-text text-xs"
:class="getPostStatusClass(post)"
>
{{ getPostStatusLabel(post) }}
</span>
@@ -180,8 +294,8 @@ const deletePost = async (post) => {
</tbody>
</table>
</div>
<p v-if="posts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
아직 작성된 글이 없습니다.
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
</p>
</section>
</template>

View File

@@ -71,10 +71,9 @@ const getTagMeta = (slug) => {
/**
* Latest 목록 데이터 변환
* @param {Object} post - API 게시물
* @param {number} index - 목록 인덱스
* @returns {Object} 화면 표시 데이터
*/
const mapLatestPost = (post, index) => {
const mapLatestPost = (post) => {
const primaryTagSlug = post.tags?.[0]
const tagMeta = getTagMeta(primaryTagSlug)
@@ -87,11 +86,12 @@ const mapLatestPost = (post, index) => {
publishedAt: formatPostDate(post.publishedAt),
publishedAtIso: post.publishedAt || '',
to: `/post/${post.slug}`,
isFeatured: index === 0
isFeatured: Boolean(post.isFeatured),
commentCount: Number(post.commentCount || 0)
}
}
const featuredPosts = computed(() => posts.value.slice(0, 6))
const featuredPosts = computed(() => posts.value.filter((post) => post.isFeatured).slice(0, 6))
const latestPosts = computed(() => posts.value.map(mapLatestPost))
const featuredTrackRef = ref(null)
@@ -233,7 +233,7 @@ const scrollFeatured = (direction) => {
</div>
</section>
<section class="py-4 px-6">
<section v-if="featuredPosts.length" class="py-4 px-6">
<div class="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
@@ -475,7 +475,7 @@ const scrollFeatured = (direction) => {
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<span>0</span>
<span>{{ post.commentCount }}</span>
</span>
</div>
</div>

View File

@@ -254,7 +254,7 @@ useHead(() => ({
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<span class="pointer-events-none">0</span>
<span class="pointer-events-none">{{ post.commentCount || 0 }}</span>
</a>
</div>

View File

@@ -14,13 +14,14 @@ const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
const tagPosts = computed(() => posts.value
.filter((post) => post.tags.includes(slug.value))
.map((post, index) => ({
.map((post) => ({
title: post.title,
excerpt: post.excerpt,
featuredImage: post.featuredImage,
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
isFeatured: index === 0,
isFeatured: Boolean(post.isFeatured),
commentCount: Number(post.commentCount || 0),
publishedAt: formatPostDate(post.publishedAt),
publishedAtIso: post.publishedAt || '',
to: `/post/${post.slug}`
@@ -99,7 +100,7 @@ const tagPosts = computed(() => posts.value
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<span>0</span>
<span>{{ post.commentCount }}</span>
</span>
</div>
</div>

View File

@@ -22,6 +22,8 @@ const mapPostRow = (row) => ({
content: row.content,
excerpt: row.excerpt,
featuredImage: row.featured_image,
isFeatured: Boolean(row.is_featured),
commentCount: Number(row.comment_count || 0),
seoTitle: row.seo_title || '',
seoDescription: row.seo_description || '',
canonicalUrl: row.canonical_url || '',
@@ -193,6 +195,12 @@ export const listPosts = async () => {
const rows = await sql`
SELECT
posts.*,
(
SELECT COUNT(*)::int
FROM comments
WHERE comments.post_id = posts.id
AND comments.status = 'published'
) AS comment_count,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
@@ -223,6 +231,12 @@ export const listAdminPosts = async () => {
const rows = await sql`
SELECT
posts.*,
(
SELECT COUNT(*)::int
FROM comments
WHERE comments.post_id = posts.id
AND comments.status = 'published'
) AS comment_count,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
@@ -249,6 +263,12 @@ export const getAdminPostById = async (id) => {
const rows = await sql`
SELECT
posts.*,
(
SELECT COUNT(*)::int
FROM comments
WHERE comments.post_id = posts.id
AND comments.status = 'published'
) AS comment_count,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
@@ -281,6 +301,7 @@ export const createAdminPost = async (input) => {
content,
excerpt,
featured_image,
is_featured,
seo_title,
seo_description,
canonical_url,
@@ -295,6 +316,7 @@ export const createAdminPost = async (input) => {
${input.content},
${input.excerpt},
${input.featuredImage},
${input.isFeatured},
${input.seoTitle},
${input.seoDescription},
${input.canonicalUrl},
@@ -336,6 +358,7 @@ export const updateAdminPost = async (id, input) => {
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},
is_featured = ${input.isFeatured},
seo_title = ${input.seoTitle},
seo_description = ${input.seoDescription},
canonical_url = ${input.canonicalUrl},
@@ -396,6 +419,12 @@ export const getPostBySlug = async (slug) => {
const rows = await sql`
SELECT
posts.*,
(
SELECT COUNT(*)::int
FROM comments
WHERE comments.post_id = posts.id
AND comments.status = 'published'
) AS comment_count,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id

View File

@@ -8,6 +8,7 @@ export const adminPostInputSchema = z.object({
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
isFeatured: z.boolean().default(false),
seoTitle: z.string().trim().default(''),
seoDescription: z.string().trim().default(''),
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),

View File

@@ -9,6 +9,8 @@ export const postSchema = z.object({
content: z.string(),
excerpt: z.string().default(''),
featuredImage: z.string().nullable().default(null),
isFeatured: z.boolean().default(false),
commentCount: z.number().int().default(0),
seoTitle: z.string().default(''),
seoDescription: z.string().default(''),
canonicalUrl: z.string().default(''),