Files
sori.studio/pages/admin/posts/index.vue
zenn 02d33996c5 관리자 목록 more vert 메뉴 통일 및 태그 메뉴 정렬 수정
AdminRowMoreMenu 공통 컴포넌트로 글·태그·페이지·미디어·네비게이션 행 액션을 ⋮ 팝오버로 통일.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 14:14:28 +09:00

499 lines
18 KiB
Vue

<script setup>
import { toAdminPostStoredTitle } from '~/lib/admin-post-title.js'
definePageMeta({
layout: 'admin'
})
const deletingId = ref('')
const togglingFeaturedId = ref('')
const openPostMenuId = 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
})
})
/**
* 게시물 수정 API 본문을 구성한다.
* @param {Object} post - 게시물
* @param {Object} [overrides] - 덮어쓸 필드
* @returns {Object} PUT 본문
*/
const buildPostUpdateBody = (post, overrides = {}) => ({
title: toAdminPostStoredTitle(post.title),
slug: post.slug,
content: post.content ?? '',
excerpt: post.excerpt ?? '',
featuredImage: post.featuredImage ?? null,
isFeatured: overrides.isFeatured ?? Boolean(post.isFeatured),
seoTitle: post.seoTitle ?? '',
seoDescription: post.seoDescription ?? '',
canonicalUrl: post.canonicalUrl ?? '',
noindex: Boolean(post.noindex),
ogImage: post.ogImage ?? null,
status: post.status,
publishedAt: post.publishedAt,
tags: post.tags ?? []
})
/**
* 열린 행 메뉴를 닫는다.
* @returns {void}
*/
const closePostMenu = () => {
openPostMenuId.value = ''
}
/**
* 행 메뉴 열기/닫기
* @param {string} postId - 게시물 ID
* @returns {void}
*/
const togglePostMenu = (postId) => {
openPostMenuId.value = openPostMenuId.value === postId ? '' : postId
}
/**
* 외부 클릭 시 행 메뉴 닫기
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const onPostMenuDocumentPointerDown = (event) => {
if (!openPostMenuId.value || !(event.target instanceof HTMLElement)) {
return
}
if (event.target.closest('[data-admin-post-row-menu]')) {
return
}
closePostMenu()
}
/**
* 게시물 추천 여부를 토글한다.
* @param {Object} post - 게시물
* @returns {Promise<void>}
*/
const togglePostFeatured = async (post) => {
closePostMenu()
togglingFeaturedId.value = post.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${post.id}`, {
method: 'PUT',
body: buildPostUpdateBody(post, { isFeatured: !post.isFeatured })
})
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '추천 설정을 변경하지 못했습니다.'
} finally {
togglingFeaturedId.value = ''
}
}
/**
* 게시물 삭제
* @param {Object} post - 삭제할 게시물
* @returns {Promise<void>} 삭제 처리 결과
*/
const deletePost = async (post) => {
closePostMenu()
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 = ''
}
}
onMounted(() => {
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
})
</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-x-auto 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 admin-posts__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</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 admin-posts__cell-actions relative w-12 px-2 py-4 text-right">
<div
class="admin-posts__row-menu relative inline-flex justify-end"
data-admin-post-row-menu
>
<button
class="admin-posts__row-menu-trigger inline-flex size-9 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#eceff2] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
type="button"
:disabled="deletingId === post.id || togglingFeaturedId === post.id"
:aria-expanded="openPostMenuId === post.id"
aria-haspopup="menu"
:aria-label="openPostMenuId === post.id ? '게시글 메뉴 닫기' : '게시글 메뉴'"
@click.stop="togglePostMenu(post.id)"
>
<span
v-if="deletingId === post.id || togglingFeaturedId === post.id"
class="text-[10px] font-semibold text-muted"
aria-hidden="true"
></span>
<svg
v-else
class="size-6 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
</svg>
</button>
<div
v-if="openPostMenuId === post.id"
class="admin-posts__row-menu-popover absolute right-0 top-full z-30 mt-1 min-w-[11rem] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
role="menu"
>
<button
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
role="menuitem"
:disabled="togglingFeaturedId === post.id"
@click="togglePostFeatured(post)"
>
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
</button>
<button
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left text-[#c0392b] hover:bg-[#fef2f2] disabled:opacity-50"
type="button"
role="menuitem"
:disabled="deletingId === post.id"
@click="deletePost(post)"
>
게시글 삭제
</button>
</div>
</div>
</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>