태그 관리 화면을 메인/일반 전환 중심으로 단순화하고 삭제 동선을 재정리.

글쓰기 Post URL 슬러그는 한글 입력 시 발음 기반 영문 소문자로 자동 생성되도록 개선.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 18:50:40 +09:00
parent cdc16c72b2
commit bd71ca860c
9 changed files with 312 additions and 149 deletions

View File

@@ -130,8 +130,8 @@ const syncPostTags = async (sql, postId, tags) => {
for (const slug of tagSlugs) {
const tagRows = await sql`
INSERT INTO tags (name, slug)
VALUES (${getTagNameFromSlug(slug)}, ${slug})
INSERT INTO tags (name, slug, tag_type, sort_order)
VALUES (${getTagNameFromSlug(slug)}, ${slug}, 'general', 0)
ON CONFLICT (slug) DO UPDATE
SET updated_at = now()
RETURNING id
@@ -533,44 +533,54 @@ export const getPageBySlug = async (slug) => {
* 공개 태그 목록 조회
* @returns {Promise<Array>} 태그 목록
*/
export const listTags = async ({ tagType } = {}) => {
export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
const sql = getPostgresClient()
const trimmedSearchQuery = String(searchQuery || '').trim().toLowerCase()
const resolvedLimit = Number.isInteger(limit) && limit > 0 ? limit : null
if (!sql) {
const sampleTags = getSampleTags().map((tag) => ({
...tag,
tagType: 'managed'
}))
if (!tagType) {
return sampleTags
let filteredTags = sampleTags
if (tagType) {
filteredTags = filteredTags.filter((tag) => tag.tagType === tagType)
}
return sampleTags.filter((tag) => tag.tagType === tagType)
if (trimmedSearchQuery) {
filteredTags = filteredTags.filter((tag) =>
tag.name.toLowerCase().includes(trimmedSearchQuery) ||
tag.slug.toLowerCase().includes(trimmedSearchQuery)
)
}
return resolvedLimit ? filteredTags.slice(0, resolvedLimit) : filteredTags
}
const rows = tagType
? await sql`
SELECT *
FROM tags
WHERE tag_type = ${tagType}
ORDER BY sort_order ASC, name ASC
`
: await sql`
SELECT *
FROM tags
ORDER BY
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
sort_order ASC,
name ASC
`
const rows = await sql`
SELECT *
FROM tags
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
)
ORDER BY
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
sort_order ASC,
name ASC
LIMIT ${resolvedLimit || 1000}
`
return rows.map(mapTagRow)
}
/**
* 관리자 태그 목록 조회
* @param {Object} options - 조회 옵션
* @returns {Promise<Array>} 관리자 태그 목록
*/
export const listAdminTags = async () => listTags()
export const listAdminTags = async (options = {}) => listTags(options)
const SEARCH_TAG_LIMIT = 12
const SEARCH_POST_LIMIT = 12

View File

@@ -1,3 +1,4 @@
import { getQuery } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { listAdminTags } from '../../../repositories/content-repository'
@@ -8,6 +9,17 @@ import { listAdminTags } from '../../../repositories/content-repository'
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
const query = getQuery(event)
const tagType = query.tagType === 'managed' || query.tagType === 'general'
? query.tagType
: undefined
const searchQuery = typeof query.q === 'string' ? query.q : ''
const parsedLimit = Number.parseInt(String(query.limit || ''), 10)
const limit = Number.isNaN(parsedLimit) ? undefined : parsedLimit
return listAdminTags()
return listAdminTags({
tagType,
searchQuery,
limit
})
})