관리자 글 목록 태그 필터와 썸네일 개선
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.10.21
|
||||
|
||||
- 관리자 글 목록의 태그 필터를 검색해서 고르는 방식으로 바꿨다.
|
||||
- 관리자 글 목록 썸네일은 원본보다 작은 카드 썸네일을 먼저 사용하도록 했다.
|
||||
|
||||
## v1.10.20
|
||||
|
||||
- 글쓰기 태그 입력창에서 최근 사용한 태그 5개를 먼저 보여 주도록 했다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.20에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.21에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -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개를 추천 목록 상단에 배치한다. 검색어가 있을 때는 기존처럼 전체 태그 이름·슬러그 검색을 유지한다.
|
||||
|
||||
@@ -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 | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
|
||||
|
||||
@@ -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개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다.
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- 관리자 글 목록 API 응답 경량화.
|
||||
- 현재 `/admin/api/posts`는 목록 화면에서도 게시물 전체 본문을 함께 내려준다.
|
||||
- 글 수와 본문 이미지/마크다운이 늘면 목록 진입 payload가 커질 수 있으므로, 목록 전용 응답에서 `content`를 제외하고 서버 페이지네이션·서버 필터링으로 전환 검토.
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.21
|
||||
|
||||
- 관리자 글 목록 태그 필터를 전체 태그 select에서 검색형 combobox로 변경.
|
||||
- 관리자 글 목록 태그 필터에서 태그 검색·키보드 선택·전체 태그 초기화를 지원하도록 추가.
|
||||
- 관리자 글 목록 썸네일이 원본 이미지 대신 `featuredImageThumbnail`을 우선 사용하도록 수정.
|
||||
|
||||
## v1.10.20
|
||||
|
||||
- 관리자 글쓰기 태그 추천 목록에서 최근 사용 태그 5개를 메인 태그보다 먼저 표시하도록 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.20",
|
||||
"version": "1.10.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user