diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index ae7b1de..90a40a6 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -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" > diff --git a/components/admin/AdminTagForm.vue b/components/admin/AdminTagForm.vue index ca5808e..99c1768 100644 --- a/components/admin/AdminTagForm.vue +++ b/components/admin/AdminTagForm.vue @@ -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' }) } @@ -105,50 +103,22 @@ const submitTag = () => { - -
- - - -
+ + +
diff --git a/docs/history.md b/docs/history.md index 3c9d354..f6566b5 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,17 @@ # 의사결정 이력 +## 2026-05-11 v0.0.82 + +### 메인 태그는 강등, 일반 태그는 검색 삭제 + +메인 태그는 홈 카테고리 노출 자산이므로 목록에서 실수 삭제보다 일반 태그로 되돌리는 강등 동작이 안전하다고 판단했다. 반대로 일반 태그는 수량이 많아 전체 목록 노출 대신 검색 중심으로 다루고, 삭제도 검색 결과 문맥에서만 허용해 운영 화면의 복잡도를 줄였다. 태그 수정 화면의 정렬/유형 입력은 목록 액션과 역할이 겹치므로 제거했다. + +## 2026-05-11 v0.0.81 + +### 태그 입력 IME 안정화와 메인 태그 전환 흐름 + +관리자 글 작성 태그 입력은 한글 조합 중 Enter 이벤트가 완성 전/완성 후로 중복 처리될 수 있어, 조합 상태에서는 태그 추가를 막고 조합 완료 후에만 확정하도록 정리했다. 또한 게시물 작성에서 자연스럽게 늘어나는 태그는 기본적으로 일반 태그로 생성하고, 카테고리로 노출할 태그만 별도 검색 후 메인 태그로 승격하도록 운영 흐름을 분리했다. + ## 2026-05-11 v0.0.80 ### 태그를 관리용/일반용으로 분리하고 관리용만 정렬 diff --git a/docs/map.md b/docs/map.md index 0841611..9963497 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 | diff --git a/docs/spec.md b/docs/spec.md index 2919209..11b792f 100644 --- a/docs/spec.md +++ b/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 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 diff --git a/docs/update.md b/docs/update.md index da9414b..32cf7ce 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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`)을 추가. diff --git a/pages/admin/tags/index.vue b/pages/admin/tags/index.vue index aaaa8c5..451e46b 100644 --- a/pages/admin/tags/index.vue +++ b/pages/admin/tags/index.vue @@ -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} 삭제 처리 결과 + * 일반 태그 검색 + * @returns {Promise} */ -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} + */ +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} + */ +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} + */ +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 = '' } } +