관리자 글 목록 태그 필터와 썸네일 개선

This commit is contained in:
2026-07-03 10:23:50 +09:00
parent b4e408dec8
commit 3f80bbb4ea
10 changed files with 240 additions and 18 deletions

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.10.21
- 관리자 글 목록의 태그 필터를 검색해서 고르는 방식으로 바꿨다.
- 관리자 글 목록 썸네일은 원본보다 작은 카드 썸네일을 먼저 사용하도록 했다.
## v1.10.20
- 글쓰기 태그 입력창에서 최근 사용한 태그 5개를 먼저 보여 주도록 했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.20에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.10.21에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-07-03 v1.10.21 — 관리자 글 목록 필터는 태그 증가를 전제로 한다
관리자 글 목록의 상태·추천·정렬 필터는 선택지가 고정되어 있어 기본 select로 충분하지만, 태그는 운영이 길어질수록 계속 늘어난다. 태그 전체를 네이티브 select에 한 줄로 누적하면 탐색성과 키보드 검색성이 떨어지므로, 글 목록 태그 필터는 검색형 combobox로 바꾸고 현재 사용 중인 태그만 후보로 보여 준다. 검색어가 없으면 전체 태그 옵션과 기존 정렬된 태그 목록을 보여 주고, 입력 중에는 태그명·슬러그 기준으로 좁힌다.
관리자 글 목록의 썸네일은 48×36px 수준의 작은 미리보기만 필요하다. 이미 게시물 API가 원본 대표 이미지와 카드 썸네일 URL을 함께 제공하므로, 목록 화면에서는 `featuredImageThumbnail`을 우선 사용해 관리자 진입 때마다 원본 이미지를 반복 다운로드하는 부담을 줄인다. 썸네일이 아직 없으면 기존 원본 fallback은 유지한다.
## 2026-07-03 v1.10.20 — 글쓰기 태그 추천은 최근 작업 흐름을 우선한다
게시물 작성자는 비슷한 주제의 글을 연속으로 작성하는 경우가 많다. 기존 Tags 입력은 빈 상태에서 메인 태그만 보여 주고 일반 태그는 검색해야 했기 때문에, 같은 태그를 반복해서 쓰는 작업에서 매번 검색이 필요했다. 이미 관리자 태그 API가 공개 발행글 기준 `lastUsedAt`을 제공하므로 별도 테이블을 만들지 않고 최근 사용 태그 5개를 추천 목록 상단에 배치한다. 검색어가 있을 때는 기존처럼 전체 태그 이름·슬러그 검색을 유지한다.

View File

@@ -186,7 +186,7 @@
|------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 날짜·구간 상세 모달, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |

View File

@@ -197,6 +197,8 @@ layouts/
- 대시보드 메뉴는 관리자 기본 페이지(`/admin`)로 이동하는 활성 링크로 표시한다.
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
- 관리자 글 목록의 태그 필터는 태그 증가에 대비해 검색형 combobox로 표시한다. 후보는 현재 글 목록에서 사용 중인 태그만 보여 주며, 검색어가 없으면 `전체 태그`와 태그명을 정렬한 목록을, 입력 중에는 태그명·슬러그 부분 일치 결과를 표시한다. 방향키와 Enter로 선택할 수 있고, 선택된 태그는 토큰의 `×`로 전체 태그 상태로 되돌린다.
- 관리자 글 목록의 대표 이미지 미리보기는 작은 카드 썸네일이 있으면 `featuredImageThumbnail`을 우선 사용하고, 없을 때만 원본 `featuredImage`를 fallback으로 사용한다.
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 태그 추천 목록을 열 수 있다. 입력이 비어 있으면 공개 발행글 기준 최근 사용 태그 5개를 먼저 표시한 뒤 메인 태그를 이어서 보여 주고, 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천한다. 추천 목록은 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다.
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 관리 화면은 `/admin/api/media?paged=1` 응답을 사용해 서버에서 탭·폴더·파일 종류·미사용·검색 조건을 적용한 뒤 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다.

View File

@@ -5,6 +5,9 @@
## 2차 관리자 개발
- 관리자 글 목록 API 응답 경량화.
- 현재 `/admin/api/posts`는 목록 화면에서도 게시물 전체 본문을 함께 내려준다.
- 글 수와 본문 이미지/마크다운이 늘면 목록 진입 payload가 커질 수 있으므로, 목록 전용 응답에서 `content`를 제외하고 서버 페이지네이션·서버 필터링으로 전환 검토.
## 프론트엔드 개발

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.10.21
- 관리자 글 목록 태그 필터를 전체 태그 select에서 검색형 combobox로 변경.
- 관리자 글 목록 태그 필터에서 태그 검색·키보드 선택·전체 태그 초기화를 지원하도록 추가.
- 관리자 글 목록 썸네일이 원본 이미지 대신 `featuredImageThumbnail`을 우선 사용하도록 수정.
## v1.10.20
- 관리자 글쓰기 태그 추천 목록에서 최근 사용 태그 5개를 메인 태그보다 먼저 표시하도록 수정.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -12,9 +12,12 @@ const postMenuStyle = ref({})
const errorMessage = ref('')
const statusFilter = ref('all')
const tagFilter = ref('all')
const tagFilterSearch = ref('')
const featuredFilter = ref('all')
const sortOrder = ref('newest')
const searchQuery = ref('')
const isTagFilterOpen = ref(false)
const activeTagFilterOptionIndex = ref(0)
const analyticsModalPost = ref(null)
const analyticsModalData = ref(createDefaultPostAnalytics())
const analyticsModalPeriod = ref('daily')
@@ -23,6 +26,7 @@ const analyticsModalChartPageOffset = ref(0)
const analyticsModalLoading = ref(false)
const analyticsModalError = ref('')
const postMenuTriggerRefs = new Map()
const tagFilterRef = ref(null)
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
default: () => []
@@ -304,6 +308,38 @@ const usedTagSlugs = computed(() => {
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
})
const usedTagOptions = computed(() => usedTagSlugs.value.map((slug) => ({
slug,
name: getTagName(slug),
color: getTagColor(slug)
})))
const tagFilterSuggestionOptions = computed(() => {
const keyword = tagFilterSearch.value.trim().toLowerCase()
const matchedTags = usedTagOptions.value
.filter((tag) => {
if (!keyword) {
return true
}
return tag.name.toLowerCase().includes(keyword) || tag.slug.toLowerCase().includes(keyword)
})
.slice(0, 20)
const shouldShowAllOption = !keyword || '전체 태그'.includes(keyword) || 'all'.includes(keyword)
return [
...(shouldShowAllOption ? [{ slug: 'all', name: '전체 태그', color: defaultTagColor }] : []),
...matchedTags
]
})
/** 태그 필터 추천 항목이 있는지 여부 */
const hasTagFilterSuggestions = computed(() => tagFilterSuggestionOptions.value.length > 0)
/** 현재 태그 필터 표시명 */
const selectedTagFilterLabel = computed(() => tagFilter.value === 'all' ? '전체 태그' : getTagName(tagFilter.value))
/** 전체 게시물 수 */
const totalPostCount = computed(() => posts.value.length)
@@ -362,6 +398,13 @@ const filteredPosts = computed(() => {
})
})
/**
* 게시물 목록 썸네일 URL을 반환한다.
* @param {Object} post - 게시물
* @returns {string} 썸네일 URL
*/
const getPostThumbnailSrc = (post) => post.featuredImageThumbnail || post.featuredImage || ''
/** 게시물 통계 모달이 열려 있는지 */
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value))
@@ -529,6 +572,91 @@ const closePostMenu = () => {
openPostMenuId.value = ''
}
/**
* 태그 필터 드롭다운을 연다.
* @returns {void}
*/
const openTagFilter = () => {
isTagFilterOpen.value = true
}
/**
* 태그 필터 드롭다운을 닫는다.
* @returns {void}
*/
const closeTagFilter = () => {
isTagFilterOpen.value = false
tagFilterSearch.value = ''
activeTagFilterOptionIndex.value = 0
}
/**
* 태그 필터 드롭다운을 토글한다.
* @returns {void}
*/
const toggleTagFilter = () => {
if (isTagFilterOpen.value) {
closeTagFilter()
return
}
openTagFilter()
}
/**
* 태그 필터 항목을 선택한다.
* @param {Object} option - 태그 필터 항목
* @returns {void}
*/
const selectTagFilterOption = (option) => {
tagFilter.value = option.slug
closeTagFilter()
}
/**
* 태그 필터를 전체 태그로 초기화한다.
* @returns {void}
*/
const clearTagFilter = () => {
tagFilter.value = 'all'
closeTagFilter()
}
/**
* 태그 필터 키 입력을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleTagFilterKeydown = (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
openTagFilter()
activeTagFilterOptionIndex.value = Math.min(
activeTagFilterOptionIndex.value + 1,
Math.max(tagFilterSuggestionOptions.value.length - 1, 0)
)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
openTagFilter()
activeTagFilterOptionIndex.value = Math.max(activeTagFilterOptionIndex.value - 1, 0)
return
}
if (event.key === 'Enter' && isTagFilterOpen.value && hasTagFilterSuggestions.value) {
event.preventDefault()
selectTagFilterOption(tagFilterSuggestionOptions.value[activeTagFilterOptionIndex.value])
return
}
if (event.key === 'Escape' && isTagFilterOpen.value) {
event.preventDefault()
closeTagFilter()
}
}
/**
* 게시물 행 메뉴 트리거 엘리먼트를 저장한다.
* @param {string} postId - 게시물 ID
@@ -590,6 +718,10 @@ const togglePostMenu = (postId) => {
* @returns {void}
*/
const onPostMenuDocumentPointerDown = (event) => {
if (isTagFilterOpen.value && event.target instanceof Node && !tagFilterRef.value?.contains(event.target)) {
closeTagFilter()
}
if (!openPostMenuId.value || !(event.target instanceof HTMLElement)) {
return
}
@@ -607,6 +739,11 @@ const onPostMenuDocumentPointerDown = (event) => {
* @returns {void}
*/
const onDocumentKeydown = (event) => {
if (event.key === 'Escape' && isTagFilterOpen.value) {
closeTagFilter()
return
}
if (event.key === 'Escape' && isAnalyticsModalOpen.value) {
closeAnalyticsModal()
}
@@ -684,6 +821,10 @@ watch(openPostMenuId, async (postId) => {
await nextTick()
updatePostMenuPosition()
})
watch(tagFilterSearch, () => {
activeTagFilterOptionIndex.value = 0
})
</script>
<template>
@@ -737,18 +878,77 @@ watch(openPostMenuId, async (postId) => {
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="admin-posts__filter">
<div
ref="tagFilterRef"
class="admin-posts__tag-filter relative w-52"
>
<span class="sr-only">태그 필터</span>
<span class="admin-posts__filter-select-wrap relative block">
<select v-model="tagFilter" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 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>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<div class="admin-posts__tag-filter-editor flex h-10 items-center gap-1 rounded border border-line bg-white px-2 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<button
v-if="tagFilter !== 'all'"
class="admin-posts__tag-filter-token inline-flex min-w-0 max-w-[8rem] items-center gap-1 rounded bg-[#f1f3f4] px-2 py-1 text-xs font-semibold text-[#394047] ring-1 ring-[#e3e6e8]"
type="button"
title="태그 필터 해제"
@click="clearTagFilter"
>
<span class="truncate">{{ selectedTagFilterLabel }}</span>
<span aria-hidden="true">×</span>
</button>
<input
v-model="tagFilterSearch"
class="admin-posts__tag-filter-input min-w-0 flex-1 bg-transparent px-1 text-sm text-[#394047] outline-none placeholder:text-[#394047]"
type="text"
role="combobox"
:aria-expanded="isTagFilterOpen"
aria-controls="admin-posts-tag-filter-list"
:placeholder="tagFilter === 'all' ? '전체 태그' : '태그 검색'"
autocomplete="off"
@input="openTagFilter"
@focus="openTagFilter"
@keydown="handleTagFilterKeydown"
>
<button
class="admin-posts__tag-filter-trigger grid size-7 shrink-0 place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4]"
type="button"
:aria-expanded="isTagFilterOpen"
aria-label="태그 필터 목록 열기"
@mousedown.prevent
@click="toggleTagFilter"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<div
v-if="isTagFilterOpen && hasTagFilterSuggestions"
id="admin-posts-tag-filter-list"
class="admin-posts__tag-filter-list absolute left-0 right-0 top-[calc(100%+0.25rem)] z-30 max-h-64 overflow-y-auto rounded-lg border border-[#d7dce0] bg-white py-1 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.14)]"
role="listbox"
>
<button
v-for="(option, index) in tagFilterSuggestionOptions"
:key="option.slug"
class="admin-posts__tag-filter-option flex w-full items-center justify-between gap-2 px-3 py-2 text-left transition-colors"
:class="index === activeTagFilterOptionIndex ? 'bg-[#f1f3f4] text-[#15171a]' : 'text-[#394047] hover:bg-[#f1f3f4]'"
type="button"
role="option"
:aria-selected="tagFilter === option.slug"
@mousedown.prevent
@click="selectTagFilterOption(option)"
>
<span class="min-w-0 truncate">{{ option.name }}</span>
<span v-if="option.slug === tagFilter" class="shrink-0 text-xs text-[#8e9cac]">선택됨</span>
</button>
</div>
<p
v-else-if="isTagFilterOpen && tagFilterSearch.trim()"
class="admin-posts__tag-filter-empty absolute left-0 right-0 top-[calc(100%+0.25rem)] z-30 rounded border border-[#d7dce0] bg-white px-3 py-2 text-xs text-muted shadow-[0_18px_50px_rgba(15,23,42,0.14)]"
>
일치하는 태그가 없습니다.
</p>
</div>
<label class="admin-posts__filter">
<span class="sr-only">추천 필터</span>
<span class="admin-posts__filter-select-wrap relative block">
@@ -818,9 +1018,9 @@ watch(openPostMenuId, async (postId) => {
<td class="admin-posts__cell admin-posts__cell-thumbnail w-16 px-2 py-4">
<span class="admin-posts__thumbnail block h-9 w-12 overflow-hidden rounded bg-[#eef1f4]">
<img
v-if="post.featuredImage"
v-if="getPostThumbnailSrc(post)"
class="h-full w-full object-cover"
:src="post.featuredImage"
:src="getPostThumbnailSrc(post)"
:alt="post.title || '게시물 썸네일'"
loading="lazy"
>