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