일반 태그 배지 목록 정리

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