태그 관리 화면을 메인/일반 전환 중심으로 단순화하고 삭제 동선을 재정리.
글쓰기 Post URL 슬러그는 한글 입력 시 발음 기반 영문 소문자로 자동 생성되도록 개선. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,7 @@ const autosaveStatus = ref('')
|
||||
const isRestoringAutosave = ref(false)
|
||||
const isSettingsOpen = ref(true)
|
||||
const tagInput = ref('')
|
||||
const isTagInputComposing = ref(false)
|
||||
const activeMediaPickerTab = ref('upload')
|
||||
const selectedMediaPickerUrl = ref('')
|
||||
|
||||
@@ -107,15 +108,44 @@ const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost
|
||||
const postUrlLabel = computed(() => form.slug || toSlug(form.title) || '')
|
||||
const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/'))
|
||||
|
||||
/**
|
||||
* 한글 음절 1자를 영문 표기로 변환
|
||||
* @param {string} char - 변환할 문자
|
||||
* @returns {string} 영문 표기
|
||||
*/
|
||||
const romanizeHangulSyllable = (char) => {
|
||||
const syllableCode = char.charCodeAt(0)
|
||||
const hangulBase = 0xac00
|
||||
const hangulLast = 0xd7a3
|
||||
if (syllableCode < hangulBase || syllableCode > hangulLast) {
|
||||
return char
|
||||
}
|
||||
|
||||
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
|
||||
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
|
||||
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
|
||||
|
||||
const offset = syllableCode - hangulBase
|
||||
const choseongIndex = Math.floor(offset / 588)
|
||||
const jungseongIndex = Math.floor((offset % 588) / 28)
|
||||
const jongseongIndex = offset % 28
|
||||
|
||||
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 슬러그
|
||||
*/
|
||||
const toSlug = (value) => value
|
||||
.normalize('NFC')
|
||||
.split('')
|
||||
.map((char) => romanizeHangulSyllable(char))
|
||||
.join('')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣\s-]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
@@ -412,6 +442,10 @@ const removeTag = (tag) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleTagKeydown = (event) => {
|
||||
if (event.isComposing || isTagInputComposing.value || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ',') {
|
||||
event.preventDefault()
|
||||
addTagFromInput()
|
||||
@@ -753,6 +787,8 @@ defineExpose({
|
||||
placeholder="태그 입력"
|
||||
@blur="addTagFromInput"
|
||||
@keydown="handleTagKeydown"
|
||||
@compositionstart="isTagInputComposing = true"
|
||||
@compositionend="isTagInputComposing = false"
|
||||
>
|
||||
<span class="admin-post-form__tag-chevron text-xs text-[#394047]" aria-hidden="true">⌄</span>
|
||||
</label>
|
||||
|
||||
@@ -22,9 +22,7 @@ const form = reactive({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || '',
|
||||
description: props.initialTag.description || '',
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: props.initialTag.color || '#15171a',
|
||||
tagType: props.initialTag.tagType || 'managed'
|
||||
color: props.initialTag.color || '#15171a'
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -64,9 +62,9 @@ const submitTag = () => {
|
||||
name: form.name.trim(),
|
||||
slug: toSlug(form.slug || form.name),
|
||||
description: form.description.trim(),
|
||||
sortOrder: form.tagType === 'managed' ? Number(form.sortOrder) || 0 : 0,
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: form.tagType
|
||||
tagType: props.initialTag.tagType || 'general'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -105,50 +103,22 @@ const submitTag = () => {
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">태그 유형</span>
|
||||
<select
|
||||
v-model="form.tagType"
|
||||
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||
>
|
||||
<option value="managed">관리용 태그 (카테고리)</option>
|
||||
<option value="general">일반 태그 (배지)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="admin-tag-form__display grid gap-4 md:grid-cols-2">
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">정렬 순서</span>
|
||||
<span class="admin-tag-form__label font-medium">색상 코드</span>
|
||||
<span class="admin-tag-form__color-row flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="form.sortOrder"
|
||||
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:disabled="form.tagType !== 'managed'"
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
|
||||
type="color"
|
||||
>
|
||||
<span v-if="form.tagType !== 'managed'" class="text-xs text-muted">
|
||||
일반 태그는 정렬 순서를 사용하지 않습니다.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">색상 코드</span>
|
||||
<span class="admin-tag-form__color-row flex items-center gap-3">
|
||||
<input
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
|
||||
type="color"
|
||||
>
|
||||
<input
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
required
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
required
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="admin-tag-form__actions flex justify-end gap-3">
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.82
|
||||
|
||||
### 메인 태그는 강등, 일반 태그는 검색 삭제
|
||||
|
||||
메인 태그는 홈 카테고리 노출 자산이므로 목록에서 실수 삭제보다 일반 태그로 되돌리는 강등 동작이 안전하다고 판단했다. 반대로 일반 태그는 수량이 많아 전체 목록 노출 대신 검색 중심으로 다루고, 삭제도 검색 결과 문맥에서만 허용해 운영 화면의 복잡도를 줄였다. 태그 수정 화면의 정렬/유형 입력은 목록 액션과 역할이 겹치므로 제거했다.
|
||||
|
||||
## 2026-05-11 v0.0.81
|
||||
|
||||
### 태그 입력 IME 안정화와 메인 태그 전환 흐름
|
||||
|
||||
관리자 글 작성 태그 입력은 한글 조합 중 Enter 이벤트가 완성 전/완성 후로 중복 처리될 수 있어, 조합 상태에서는 태그 추가를 막고 조합 완료 후에만 확정하도록 정리했다. 또한 게시물 작성에서 자연스럽게 늘어나는 태그는 기본적으로 일반 태그로 생성하고, 카테고리로 노출할 태그만 별도 검색 후 메인 태그로 승격하도록 운영 흐름을 분리했다.
|
||||
|
||||
## 2026-05-11 v0.0.80
|
||||
|
||||
### 태그를 관리용/일반용으로 분리하고 관리용만 정렬
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 |
|
||||
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리 |
|
||||
| pages/admin/tags/index.vue | 태그 관리(관리용/일반용 분리, 관리용 드래그 정렬 저장) |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제) |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||
@@ -151,12 +151,12 @@
|
||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||
| server/routes/admin/api/tags/[id].put.js | 관리자 태그 수정 API |
|
||||
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 관리용 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
|
||||
14
docs/spec.md
14
docs/spec.md
@@ -264,7 +264,7 @@ components/content/
|
||||
| name | String | 태그명 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| description | String | 설명 |
|
||||
| sort_order | Integer | 관리용 태그 정렬 순서 |
|
||||
| sort_order | Integer | 메인 태그 정렬 순서 |
|
||||
| color | String | 태그 색상 코드 |
|
||||
| tag_type | Enum | 태그 유형(`managed`/`general`) |
|
||||
| created_at | DateTime | 생성일 |
|
||||
@@ -389,12 +389,12 @@ components/content/
|
||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
||||
- `GET /admin/api/tags` - 태그 목록
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
- `PUT /admin/api/tags/:id` - 태그 수정
|
||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||
- `PUT /admin/api/tags/reorder` - 관리용 태그 순서 일괄 저장
|
||||
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
@@ -404,9 +404,11 @@ components/content/
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 공개 `GET /api/tags`는 `managed` 태그만 반환한다.
|
||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 관리용 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
@@ -617,6 +619,6 @@ APP_PORT=43118
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.80
|
||||
- 현재 버전: v0.0.82
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.82
|
||||
|
||||
- 메인 태그 목록의 `삭제` 버튼을 제거하고 `일반 태그로 변경`(강등) 버튼으로 교체.
|
||||
- 일반 태그 삭제는 일반 태그 검색 결과 영역에서만 가능하도록 변경.
|
||||
- 태그 생성/수정 폼에서 정렬 순서 입력과 태그 유형 선택을 제거해, 메인/일반 전환은 태그 목록 액션으로만 처리하도록 단순화.
|
||||
|
||||
## v0.0.81
|
||||
|
||||
- 관리자 글 작성 태그 입력에서 한글 조합 중 Enter 입력 시 중복 태그가 생성되지 않도록 IME 조합 상태 가드를 추가.
|
||||
- 게시물 저장 중 새로 생성되는 태그는 기본값을 `general`(일반 태그)로 저장하도록 수정.
|
||||
- 관리자 태그 화면에서 `관리용 태그` 명칭을 `메인 태그`로 변경.
|
||||
- 관리자 태그 화면의 일반 태그 전체 목록 테이블을 제거하고, 일반 태그 검색 후 `메인 태그로 전환`하는 흐름으로 개편.
|
||||
- 관리자 태그 목록 API에 `tagType`, `q`, `limit` 조회 옵션을 추가해 일반 태그 검색을 지원.
|
||||
|
||||
## v0.0.80
|
||||
|
||||
- 태그에 유형(`managed`/`general`) 컬럼을 추가하는 마이그레이션(`015_add_tag_type_and_reorder_support.sql`)을 추가.
|
||||
|
||||
@@ -3,19 +3,23 @@ definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const deletingId = ref('')
|
||||
const draggingTagId = ref('')
|
||||
const dragOverTagId = ref('')
|
||||
const savingOrder = ref(false)
|
||||
const promotingTagId = ref('')
|
||||
const demotingTagId = ref('')
|
||||
const deletingGeneralTagId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const infoMessage = ref('')
|
||||
const generalTagQuery = ref('')
|
||||
const generalTagSearchResults = ref([])
|
||||
const generalTagSearchLoading = ref(false)
|
||||
|
||||
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'))
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 시작
|
||||
@@ -112,13 +116,9 @@ const saveManagedOrder = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const managedTagMap = new Map(reordered.map((tag) => [tag.id, tag]))
|
||||
tags.value = [
|
||||
...reordered,
|
||||
...generalTags.value.map((tag) => managedTagMap.get(tag.id) || tag)
|
||||
]
|
||||
tags.value = [...reordered]
|
||||
await refresh()
|
||||
infoMessage.value = '관리용 태그 순서가 저장되었습니다.'
|
||||
infoMessage.value = '메인 태그 순서가 저장되었습니다.'
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '정렬 순서를 저장하지 못했습니다.'
|
||||
} finally {
|
||||
@@ -127,16 +127,118 @@ const saveManagedOrder = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 삭제
|
||||
* @param {Object} tag - 삭제할 태그
|
||||
* @returns {Promise<void>} 삭제 처리 결과
|
||||
* 일반 태그 검색
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteTag = async (tag) => {
|
||||
if (!confirm(`"${tag.name}" 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
|
||||
const searchGeneralTags = async () => {
|
||||
const keyword = generalTagQuery.value.trim()
|
||||
if (!keyword) {
|
||||
generalTagSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = tag.id
|
||||
generalTagSearchLoading.value = true
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
|
||||
query: {
|
||||
tagType: 'general',
|
||||
q: keyword,
|
||||
limit: 30
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '일반 태그 검색에 실패했습니다.'
|
||||
} finally {
|
||||
generalTagSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 태그를 메인 태그로 전환한다.
|
||||
* @param {Object} tag - 대상 태그
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const promoteToMainTag = async (tag) => {
|
||||
if (promotingTagId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
promotingTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || '',
|
||||
sortOrder: (managedTags.value.length + 1) * 10,
|
||||
color: tag.color || '#15171a',
|
||||
tagType: 'managed'
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
await searchGeneralTags()
|
||||
infoMessage.value = `"${tag.name}" 태그를 메인 태그로 전환했습니다.`
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '메인 태그 전환에 실패했습니다.'
|
||||
} finally {
|
||||
promotingTagId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 태그를 일반 태그로 강등한다.
|
||||
* @param {Object} tag - 대상 태그
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const demoteToGeneralTag = async (tag) => {
|
||||
if (demotingTagId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
demotingTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || '',
|
||||
sortOrder: 0,
|
||||
color: tag.color || '#15171a',
|
||||
tagType: 'general'
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
await searchGeneralTags()
|
||||
infoMessage.value = `"${tag.name}" 태그를 일반 태그로 변경했습니다.`
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '일반 태그 변경에 실패했습니다.'
|
||||
} finally {
|
||||
demotingTagId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색된 일반 태그를 삭제한다.
|
||||
* @param {Object} tag - 대상 태그
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteGeneralTag = async (tag) => {
|
||||
if (!confirm(`"${tag.name}" 일반 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingGeneralTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
@@ -145,12 +247,15 @@ const deleteTag = async (tag) => {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
await searchGeneralTags()
|
||||
infoMessage.value = `"${tag.name}" 일반 태그를 삭제했습니다.`
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
|
||||
errorMessage.value = error?.data?.message || '일반 태그를 삭제하지 못했습니다.'
|
||||
} finally {
|
||||
deletingId.value = ''
|
||||
deletingGeneralTagId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -169,7 +274,7 @@ const deleteTag = async (tag) => {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
관리용 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 추후 배지형 노출 용도로 사용됩니다.
|
||||
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<p v-if="errorMessage" class="admin-tags__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
@@ -181,7 +286,7 @@ const deleteTag = async (tag) => {
|
||||
|
||||
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
|
||||
<div class="flex items-center justify-between 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>
|
||||
<button
|
||||
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
@@ -238,12 +343,12 @@ const deleteTag = async (tag) => {
|
||||
수정
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingId === tag.id"
|
||||
@click="deleteTag(tag)"
|
||||
:disabled="demotingTagId === tag.id"
|
||||
@click="demoteToGeneralTag(tag)"
|
||||
>
|
||||
{{ deletingId === tag.id ? '삭제 중' : '삭제' }}
|
||||
{{ demotingTagId === tag.id ? '변경 중' : '제외' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -254,53 +359,55 @@ const deleteTag = async (tag) => {
|
||||
|
||||
<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">
|
||||
<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="일반 태그 이름 또는 슬러그 검색"
|
||||
@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>
|
||||
<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)"
|
||||
>
|
||||
{{ 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"
|
||||
:disabled="deletingGeneralTagId === tag.id"
|
||||
@click="deleteGeneralTag(tag)"
|
||||
>
|
||||
{{ deletingGeneralTagId === tag.id ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="generalTagQuery.trim() && !generalTagSearchLoading" class="text-sm text-muted">
|
||||
검색 결과가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-tags__cell px-4 py-3">색상</th>
|
||||
<th class="admin-tags__cell px-4 py-3">이름</th>
|
||||
<th class="admin-tags__cell px-4 py-3">슬러그</th>
|
||||
<th class="admin-tags__cell px-4 py-3">설명</th>
|
||||
<th class="admin-tags__cell px-4 py-3">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="tag in generalTags" :key="tag.id" class="admin-tags__row">
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<span class="admin-tags__color flex items-center gap-2">
|
||||
<span class="admin-tags__color-swatch h-5 w-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="admin-tags__color-code text-xs text-muted">{{ tag.color }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 font-semibold">
|
||||
{{ tag.name }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.slug }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.description || '-' }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<div class="admin-tags__actions flex gap-2">
|
||||
<NuxtLink class="admin-tags__edit rounded border border-line px-3 py-1.5 text-xs font-semibold" :to="`/admin/tags/${tag.id}`">
|
||||
수정
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingId === tag.id"
|
||||
@click="deleteTag(tag)"
|
||||
>
|
||||
{{ deletingId === tag.id ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="tags.length === 0" class="admin-tags__empty mt-6 text-sm text-muted">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user