일반 태그 배지 목록 정리

This commit is contained in:
2026-05-15 10:50:25 +09:00
parent 9e544d97fa
commit 536ee7079e
9 changed files with 142 additions and 63 deletions

View File

@@ -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
})
}
</script>

View File

@@ -1,5 +1,9 @@
# 업데이트 요약
## v1.1.6
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
## v1.1.5
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-15 v1.1.6
### 일반 태그도 검색 없이 보이는 관리 화면
메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다.
## 2026-05-15 v1.1.5
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공

View File

@@ -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` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v1.1.6
- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정.
- 일반 태그 목록을 배지형 flex-wrap UI로 표시하고, 최근 사용/수정 태그가 앞에 오도록 정렬.
- 일반 태그 배지 목록에 최근 사용순·많이 사용순·이름순 정렬 전환을 추가.
- 일반 태그 배지에서 이름·슬러그 로컬 필터, 메인 태그 전환, 삭제를 바로 수행하도록 정리.
- 관리자 태그 추가 화면에서 새 태그는 일반 태그로 유지하되, 생성 후 태그 관리 화면에서 누락처럼 보이지 않게 정리.
- 패키지 버전 `1.1.6`으로 갱신.
## v1.1.5
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.

4
package-lock.json generated
View File

@@ -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",

View File

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

View File

@@ -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<void>}
*/
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(() => {
</NuxtLink>
</div>
<p class="mt-3 text-xs text-muted">
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 있습니다.
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다.
</p>
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
@@ -404,44 +423,65 @@ onBeforeUnmount(() => {
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
<div class="border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">일반 태그 검색</p>
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
</div>
<div class="space-y-3 bg-white p-4">
<div class="flex gap-2">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<input
v-model="generalTagQuery"
type="text"
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
placeholder="일반 태그 이름 또는 슬러그 검색"
placeholder="일반 태그 이름 또는 슬러그 필터"
@keydown.enter.prevent="searchGeneralTags"
>
<button
type="button"
class="h-10 rounded border border-line bg-white px-4 text-sm font-semibold disabled:opacity-50"
:disabled="generalTagSearchLoading"
@click="searchGeneralTags"
>
{{ generalTagSearchLoading ? '검색 중' : '검색' }}
</button>
</div>
<div v-if="generalTagSearchResults.length" class="divide-y divide-line rounded border border-line">
<div v-for="tag in generalTagSearchResults" :key="tag.id" class="flex items-center gap-3 px-3 py-2.5">
<span class="h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-ink">{{ tag.name }}</p>
<p class="truncate text-xs text-muted">{{ tag.slug }}</p>
</div>
<div class="inline-flex shrink-0 rounded border border-line bg-[#f7f7f5] p-1">
<button
type="button"
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'recent' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('recent')"
>
{{ promotingTagId === tag.id ? '전환 중' : '메인 태그로 전환' }}
최근 사용순
</button>
<button
type="button"
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'count' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('count')"
>
많이 사용순
</button>
<button
type="button"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'name' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('name')"
>
이름순
</button>
</div>
</div>
<div v-if="filteredGeneralTags.length" class="flex flex-wrap gap-2">
<div
v-for="tag in filteredGeneralTags"
:key="tag.id"
class="admin-tags__general-badge group inline-flex max-w-full items-center gap-2 rounded-full border border-line bg-[#f7f7f5] px-3 py-2 text-sm"
:title="tag.slug"
>
<span class="h-3 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="truncate font-semibold text-ink">{{ tag.name }}</span>
<span class="text-xs text-muted">{{ tag.postCount || 0 }}</span>
<button
type="button"
class="rounded-full border border-line bg-white px-2 py-1 text-[11px] font-semibold disabled:opacity-50"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
>
{{ promotingTagId === tag.id ? '전환 중' : '메인' }}
</button>
<button
type="button"
class="rounded-full border border-red-200 bg-white px-2 py-1 text-[11px] font-semibold text-red-700 disabled:opacity-50"
:disabled="deletingGeneralTagId === tag.id"
@click="deleteGeneralTag(tag)"
>
@@ -449,8 +489,8 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<p v-else-if="generalTagQuery.trim() && !generalTagSearchLoading" class="text-sm text-muted">
검색 결과가 없습니다.
<p v-else class="text-sm text-muted">
{{ generalTagQuery.trim() ? '일치하는 일반 태그가 없습니다.' : '아직 일반 태그가 없습니다.' }}
</p>
</div>
</div>

View File

@@ -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}
`