Files
sori.studio/pages/admin/posts/index.vue
zenn 14ce897bf8 v1.2.0: 관리자 글 목록·슬러그·예약 시각 UX 정리
발행일 기준 목록 정렬, 추천 필터·별 표시, 슬러그 자동/수동 구분, 예약 날짜·시간 클릭 영역 수정.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 17:48:19 +09:00

368 lines
14 KiB
Vue

<script setup>
definePageMeta({
layout: 'admin'
})
const deletingId = ref('')
const errorMessage = ref('')
const statusFilter = ref('all')
const tagFilter = ref('all')
const featuredFilter = 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: () => []
})
const { data: siteSettings } = await useFetch('/admin/api/settings', {
default: () => ({ showPostUpdatedAt: false })
})
/**
* 게시물 발행일 라벨을 반환한다.
* @param {Object} post - 게시물
* @returns {string} 발행일 또는 '-'
*/
const getPublishDateLabel = (post) => {
if (!post.publishedAt) {
return '-'
}
return formatPostDateTime(post.publishedAt)
}
/**
* 게시물 수정일 보조 라벨을 반환한다.
* @param {Object} post - 게시물
* @returns {string} 수정일 라벨 또는 빈 문자열
*/
const getUpdatedDateLabel = (post) => {
if (!siteSettings.value?.showPostUpdatedAt || !wasPostUpdatedAfterPublish(post)) {
return ''
}
return `수정: ${formatPostDateTime(post.updatedAt)}`
}
/**
* 목록 정렬용 시각(ms). 발행일이 있으면 발행일, 없으면 수정일(초안 등).
* @param {Object} post - 게시물
* @returns {number} 정렬 기준 시각
*/
const getListSortTimestamp = (post) => {
const sortAt = post.publishedAt || post.updatedAt || post.createdAt
return new Date(sortAt || 0).getTime()
}
/**
* 게시물 공개 여부 확인
* @param {Object} post - 게시물
* @returns {boolean} 공개 여부
*/
const isPublicPost = (post) => post.status === 'published'
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
/**
* 게시물 상태 필터 키 생성
* @param {Object} post - 게시물
* @returns {'published' | 'scheduled' | 'draft'} 상태 키
*/
const getPostStatusKey = (post) => {
if (post.status === 'published' && !isPublicPost(post)) {
return 'scheduled'
}
if (post.status === 'published') {
return 'published'
}
return 'draft'
}
/**
* 게시물 상태 표시 문자열 생성
* @param {Object} post - 게시물
* @returns {string} 상태 표시 문자열
*/
const getPostStatusLabel = (post) => {
const statusKey = getPostStatusKey(post)
if (statusKey === 'scheduled') {
return '예약'
}
if (statusKey === 'published') {
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 totalPostCount = computed(() => posts.value.length)
/** 추천(`isFeatured`) 게시물 수 */
const featuredPostCount = computed(() => posts.value.filter((post) => post.isFeatured).length)
/** 필터가 적용됐는지 */
const hasActiveListFilters = computed(() => statusFilter.value !== 'all'
|| tagFilter.value !== 'all'
|| featuredFilter.value !== 'all')
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)
const matchesFeatured = featuredFilter.value === 'all'
|| (featuredFilter.value === 'featured' && post.isFeatured)
return matchesStatus && matchesTag && matchesFeatured
})
return [...filtered].sort((a, b) => {
const left = getListSortTimestamp(a)
const right = getListSortTimestamp(b)
return sortOrder.value === 'oldest' ? left - right : right - left
})
})
/**
* 게시물 삭제
* @param {Object} post - 삭제할 게시물
* @returns {Promise<void>} 삭제 처리 결과
*/
const deletePost = async (post) => {
if (!confirm(`"${post.title}" 글을 삭제할까요?`)) {
return
}
deletingId.value = post.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${post.id}`, {
method: 'DELETE'
})
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
} finally {
deletingId.value = ''
}
}
</script>
<template>
<section class="admin-posts bg-paper p-6">
<div class="admin-posts__header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
Posts
</p>
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
목록
</h1>
<p class="admin-posts__count mt-2 text-sm text-muted">
{{ totalPostCount }}
<template v-if="featuredPostCount > 0">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-featured">추천 {{ featuredPostCount }}</span>
</template>
<template v-if="hasActiveListFilters && filteredPosts.length !== totalPostCount">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-filtered">표시 {{ filteredPosts.length }}</span>
</template>
</p>
</div>
<div class="admin-posts__header-actions flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 lg:flex-nowrap lg:justify-end">
<div class="admin-posts__filters flex min-w-0 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="featuredFilter" 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="featured">추천만</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>
<NuxtLink class="admin-posts__new shrink-0 rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/posts/new">
</NuxtLink>
</div>
</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>
<div class="admin-posts__table mt-8 overflow-hidden border border-line">
<table class="admin-posts__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-3 text-center">
<span class="sr-only">추천</span>
<span class="inline-flex size-4 items-center justify-center text-[#f5a623]" aria-hidden="true"></span>
</th>
<th class="admin-posts__cell px-4 py-3">제목</th>
<th class="admin-posts__cell px-4 py-3">상태</th>
<th class="admin-posts__cell px-4 py-3">태그</th>
<th class="admin-posts__cell px-4 py-3">발행일</th>
<th class="admin-posts__cell px-4 py-3">관리</th>
</tr>
</thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
<td class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-4 text-center">
<span
v-if="post.isFeatured"
class="admin-posts__featured-star inline-flex size-8 items-center justify-center text-[#f5a623]"
title="추천 글"
aria-label="추천 "
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</span>
</td>
<td class="admin-posts__cell px-4 py-4">
<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-text text-xs"
:class="getPostStatusClass(post)"
>
{{ getPostStatusLabel(post) }}
</span>
</td>
<td class="admin-posts__cell px-4 py-4">
<div v-if="post.tags.length" class="admin-posts__tag-list flex flex-wrap gap-1.5">
<span
v-for="tag in post.tags"
:key="tag"
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] bg-[#ecd2de] px-2 text-xs font-semibold text-[#e04e87]"
>
{{ tag }}
</span>
</div>
<span v-else class="admin-posts__tag-empty text-muted">-</span>
</td>
<td class="admin-posts__cell px-4 py-4">
<p class="admin-posts__publish-date text-[#394047]">
{{ getPublishDateLabel(post) }}
</p>
<p v-if="getUpdatedDateLabel(post)" class="admin-posts__updated-date mt-1 text-xs text-muted">
{{ getUpdatedDateLabel(post) }}
</p>
</td>
<td class="admin-posts__cell px-4 py-4">
<button
class="admin-posts__delete-icon inline-flex size-9 items-center justify-center rounded text-muted opacity-35 transition-all hover:opacity-100 hover:text-red-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-20"
type="button"
:disabled="deletingId === post.id"
:aria-label="deletingId === post.id ? '삭제 ' : '삭제'"
@click="deletePost(post)"
>
<span v-if="deletingId === post.id" class="admin-posts__delete-progress text-[10px] font-semibold text-muted" aria-hidden="true"></span>
<svg
v-else
class="size-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
</p>
</section>
</template>