게시물 추천과 관리자 목록 필터 정리
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user