From 536ee7079ef121021d57bd6a58e183643b49958b Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 15 May 2026 10:50:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=BC=EB=B0=98=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminTagForm.vue | 6 +- docs/changelog.md | 4 + docs/history.md | 6 + docs/spec.md | 6 +- docs/update.md | 9 ++ package-lock.json | 4 +- package.json | 2 +- pages/admin/tags/index.vue | 144 ++++++++++++++-------- server/repositories/content-repository.js | 24 +++- 9 files changed, 142 insertions(+), 63 deletions(-) diff --git a/components/admin/AdminTagForm.vue b/components/admin/AdminTagForm.vue index 99c1768..a51a9d4 100644 --- a/components/admin/AdminTagForm.vue +++ b/components/admin/AdminTagForm.vue @@ -11,6 +11,10 @@ const props = defineProps({ saving: { type: Boolean, default: false + }, + defaultTagType: { + type: String, + default: 'general' } }) @@ -64,7 +68,7 @@ const submitTag = () => { description: form.description.trim(), sortOrder: props.initialTag.sortOrder ?? 0, color: form.color, - tagType: props.initialTag.tagType || 'general' + tagType: props.initialTag.tagType || props.defaultTagType }) } diff --git a/docs/changelog.md b/docs/changelog.md index addb690..d0b8627 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.1.6 + +- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정. + ## v1.1.5 - 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강. diff --git a/docs/history.md b/docs/history.md index f753fe2..7f53bcc 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-15 v1.1.6 + +### 일반 태그도 검색 없이 보이는 관리 화면 + +메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다. + ## 2026-05-15 v1.1.5 ### 운영 업로드 파일을 런타임 볼륨에서 직접 제공 diff --git a/docs/spec.md b/docs/spec.md index 03e3915..63f24e8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -443,11 +443,13 @@ components/content/ > 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다. > 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다. > 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다. -> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다. +> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다. +> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다. > 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다. > 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다. +> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다. > 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다. -> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다. +> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다. > 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다. > 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다. diff --git a/docs/update.md b/docs/update.md index 308b66f..b2f1a25 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v1.1.6 + +- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정. +- 일반 태그 목록을 배지형 flex-wrap UI로 표시하고, 최근 사용/수정 태그가 앞에 오도록 정렬. +- 일반 태그 배지 목록에 최근 사용순·많이 사용순·이름순 정렬 전환을 추가. +- 일반 태그 배지에서 이름·슬러그 로컬 필터, 메인 태그 전환, 삭제를 바로 수행하도록 정리. +- 관리자 태그 추가 화면에서 새 태그는 일반 태그로 유지하되, 생성 후 태그 관리 화면에서 누락처럼 보이지 않게 정리. +- 패키지 버전 `1.1.6`으로 갱신. + ## v1.1.5 - 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가. diff --git a/package-lock.json b/package-lock.json index 9c922bf..5f3d08a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5920,7 +5920,7 @@ } }, "node_modules/dlv": { - "version": "1.1.5", + "version": "1.1.6", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" @@ -6920,7 +6920,7 @@ "license": "MIT" }, "node_modules/impound": { - "version": "1.1.5", + "version": "1.1.6", "resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz", "integrity": "sha512-5AUn+QE0UofqNHu5f2Skf6Svukdg4ehOIq8O0EtqIx4jta0CDZYBPqpIHt0zrlUTiFVYlLpeH39DoikXBjPKpA==", "license": "MIT", diff --git a/package.json b/package.json index cec48e8..7c1082e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.1.5", + "version": "1.1.6", "private": true, "type": "module", "imports": { diff --git a/pages/admin/tags/index.vue b/pages/admin/tags/index.vue index 7b655c2..4e25ca4 100644 --- a/pages/admin/tags/index.vue +++ b/pages/admin/tags/index.vue @@ -12,14 +12,47 @@ const deletingGeneralTagId = ref('') const toast = ref(null) let toastTimer = null const generalTagQuery = ref('') -const generalTagSearchResults = ref([]) -const generalTagSearchLoading = ref(false) +const generalTagSortMode = ref('recent') const { data: tags, refresh } = await useFetch('/admin/api/tags', { default: () => [] }) const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed')) +const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general')) +const filteredGeneralTags = computed(() => { + const keyword = generalTagQuery.value.trim().toLowerCase() + const sortedTags = [...generalTags.value].sort((a, b) => { + if (generalTagSortMode.value === 'count') { + const countDiff = Number(b.postCount || 0) - Number(a.postCount || 0) + if (countDiff !== 0) { + return countDiff + } + } + + if (generalTagSortMode.value === 'name') { + return a.name.localeCompare(b.name, 'ko') + } + + const aTime = new Date(a.lastUsedAt || a.updatedAt || 0).getTime() + const bTime = new Date(b.lastUsedAt || b.updatedAt || 0).getTime() + + if (aTime !== bTime) { + return bTime - aTime + } + + return a.name.localeCompare(b.name, 'ko') + }) + + if (!keyword) { + return sortedTags + } + + return sortedTags.filter((tag) => + tag.name.toLowerCase().includes(keyword) || + tag.slug.toLowerCase().includes(keyword) + ) +}) /** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */ const baselineManagedTagIds = ref([]) @@ -184,27 +217,16 @@ const saveManagedOrder = async () => { * @returns {Promise} */ const searchGeneralTags = async () => { - const keyword = generalTagQuery.value.trim() - if (!keyword) { - generalTagSearchResults.value = [] - return - } + generalTagQuery.value = generalTagQuery.value.trim() +} - generalTagSearchLoading.value = true - - try { - generalTagSearchResults.value = await $fetch('/admin/api/tags', { - query: { - tagType: 'general', - q: keyword, - limit: 30 - } - }) - } catch (error) { - showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.') - } finally { - generalTagSearchLoading.value = false - } +/** + * 일반 태그 정렬 기준을 변경한다. + * @param {'recent'|'count'|'name'} mode - 정렬 기준 + * @returns {void} + */ +const setGeneralTagSortMode = (mode) => { + generalTagSortMode.value = mode } /** @@ -232,7 +254,6 @@ const promoteToMainTag = async (tag) => { } }) await refreshTagsFromServer() - await searchGeneralTags() showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`) } catch (error) { showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.') @@ -266,7 +287,6 @@ const demoteToGeneralTag = async (tag) => { } }) await refreshTagsFromServer() - await searchGeneralTags() showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`) } catch (error) { showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.') @@ -292,7 +312,6 @@ const deleteGeneralTag = async (tag) => { method: 'DELETE' }) await refreshTagsFromServer() - await searchGeneralTags() showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`) } catch (error) { showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.') @@ -323,7 +342,7 @@ onBeforeUnmount(() => {

- 메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 수 있습니다. + 메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 때 메인 태그로 전환할 수 있습니다.

@@ -404,44 +423,65 @@ onBeforeUnmount(() => {
-

일반 태그 검색

+

일반 태그

-
+
- -
-
-
- -
-

{{ tag.name }}

-

{{ tag.slug }}

-
+
+ +
+
+
+
+ + {{ tag.name }} + {{ tag.postCount || 0 }} + +
-

- 검색 결과가 없습니다. +

+ {{ generalTagQuery.trim() ? '일치하는 일반 태그가 없습니다.' : '아직 일반 태그가 없습니다.' }}

diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js index d9e64de..70c28b6 100644 --- a/server/repositories/content-repository.js +++ b/server/repositories/content-repository.js @@ -61,7 +61,10 @@ const mapTagRow = (row) => ({ description: row.description, sortOrder: row.sort_order, color: row.color, - tagType: row.tag_type || 'managed' + tagType: row.tag_type || 'managed', + postCount: Number(row.post_count || 0), + lastUsedAt: row.last_used_at ? row.last_used_at.toISOString() : null, + updatedAt: row.updated_at ? row.updated_at.toISOString() : null }) /** @@ -572,7 +575,10 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => { if (!sql) { const sampleTags = getSampleTags().map((tag) => ({ ...tag, - tagType: 'managed' + tagType: 'managed', + postCount: 0, + lastUsedAt: null, + updatedAt: null })) let filteredTags = sampleTags if (tagType) { @@ -588,17 +594,25 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => { } const rows = await sql` - SELECT * + SELECT + tags.*, + COUNT(post_tags.post_id)::int AS post_count, + MAX(posts.updated_at) AS last_used_at FROM tags + LEFT JOIN post_tags ON post_tags.tag_id = tags.id + LEFT JOIN posts ON posts.id = post_tags.post_id WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null}) AND ( ${trimmedSearchQuery || null}::text IS NULL - OR strpos(lower(name), ${trimmedSearchQuery || ''}) > 0 - OR strpos(lower(slug), ${trimmedSearchQuery || ''}) > 0 + OR strpos(lower(tags.name), ${trimmedSearchQuery || ''}) > 0 + OR strpos(lower(tags.slug), ${trimmedSearchQuery || ''}) > 0 ) + GROUP BY tags.id ORDER BY CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC, sort_order ASC, + MAX(posts.updated_at) DESC NULLS LAST, + tags.updated_at DESC, name ASC LIMIT ${resolvedLimit || 1000} `