diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue
index 374c8f0..f5704ac 100644
--- a/components/admin/AdminPostForm.vue
+++ b/components/admin/AdminPostForm.vue
@@ -68,6 +68,7 @@ const isUploadingFeaturedImage = ref(false)
const isSettingsOpen = ref(true)
const tagInput = ref('')
const tagInputRef = ref(null)
+const pendingTagNames = ref({})
const isTagSuggestionsOpen = ref(false)
const activeTagSuggestionIndex = ref(0)
const isTagInputComposing = ref(false)
@@ -421,12 +422,24 @@ const normalizeTagToken = (value) => {
}
return raw
+ .replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9가-힣-]+/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
+/**
+ * 새 태그의 화면 표시 이름을 정리한다.
+ * @param {string} value - 원본 태그 입력값
+ * @returns {string} 표시 이름
+ */
+const normalizeTagDisplayName = (value) => String(value)
+ .normalize('NFC')
+ .trim()
+ .replace(/\s*\/\s*/g, '/')
+ .replace(/\s+/g, ' ')
+
/**
* 슬러그 입력 포커스 아웃 시 정규화·자동 모드 복귀
* @returns {void}
@@ -632,7 +645,8 @@ const createPostPayload = (options = {}) => {
ogImage: null,
status: form.status,
publishedAt,
- tags: selectedTags.value
+ tags: selectedTags.value,
+ tagNames: Object.fromEntries(selectedTags.value.map((tag) => [tag, getTagDisplayName(tag)]))
}
}
@@ -807,6 +821,7 @@ const removeFeaturedImage = () => {
* @returns {void}
*/
const addTagToken = (value) => {
+ const displayName = normalizeTagDisplayName(value)
const nextTag = normalizeTagToken(value)
if (!nextTag) {
@@ -825,6 +840,14 @@ const addTagToken = (value) => {
nextTags.push(nextTag)
}
+ const hasExistingTag = availableAdminTags.value.some((tag) => tag.slug === nextTag)
+ if (!hasExistingTag && displayName) {
+ pendingTagNames.value = {
+ ...pendingTagNames.value,
+ [nextTag]: displayName
+ }
+ }
+
form.tagsText = nextTags.join(', ')
tagInput.value = ''
activeTagSuggestionIndex.value = 0
@@ -863,7 +886,9 @@ const removeTag = (tag) => {
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 표시 이름
*/
-const getTagDisplayName = (slug) => availableAdminTags.value.find((tag) => tag.slug === slug)?.name || slug
+const getTagDisplayName = (slug) => availableAdminTags.value.find((tag) => tag.slug === slug)?.name
+ || pendingTagNames.value[slug]
+ || slug
/**
* 태그 슬러그의 고유 색상을 반환한다.
diff --git a/components/content/ContentMarkdownCodeBlockEditor.vue b/components/content/ContentMarkdownCodeBlockEditor.vue
index 0f06316..308229b 100644
--- a/components/content/ContentMarkdownCodeBlockEditor.vue
+++ b/components/content/ContentMarkdownCodeBlockEditor.vue
@@ -138,7 +138,7 @@ const toggleLineNumbers = () => {
@mousedown.prevent
@click="toggleLineNumbers"
>
- {{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
+ {{ lineNumbersEnabled ? '줄번호' : '줄번호 표시 안함' }}
로컬 기준 v1.8.3에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
+> 로컬 기준 v1.8.4에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,12 @@
## 로컬 개발
+### v1.8.4 참고
+
+- 추가 DB 마이그레이션은 없다.
+- 게시물 글쓰기에서 새 태그 `Xenoblade 3`를 입력해 Enter를 누르면 배지에 입력한 대소문자와 공백이 유지되는지 확인한다.
+- 새 태그 `Xenoblade3/Hero`를 저장하면 태그명은 그대로 유지되고 슬러그는 `xenoblade3-hero`로 저장되는지 확인한다.
+
### v1.8.3 참고
- 추가 DB 마이그레이션은 없다.
diff --git a/docs/map.md b/docs/map.md
index 26e6f9e..dad18a7 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -113,7 +113,7 @@
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
-| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 카드 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
+| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 카드 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
diff --git a/docs/spec.md b/docs/spec.md
index 8e3b23e..14734ef 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -125,6 +125,7 @@
- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다.
- `/tags` - 태그 전체 목록
- `/tag/:slug` - 태그별 게시물 목록
+- 태그명에 `/`를 입력하면 태그명은 그대로 저장하고 슬러그에서는 `/`를 `-`로 변환한다. 예: `Xenoblade3/Hero` 태그명은 `xenoblade3-hero` 슬러그로 저장한다.
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
- `/signin` - 로그인
diff --git a/docs/update.md b/docs/update.md
index fc3a466..ea558a5 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,11 @@
# 업데이트 이력
+## v1.8.4
+
+- 게시물 글쓰기에서 새 태그 배지에 슬러그 대신 입력한 태그명을 표시하도록 수정.
+- 새 태그 저장 시 입력한 태그명을 유지하고 `/`는 슬러그에서 `-`로 변환하도록 수정.
+- 라이브 코드 블록의 줄번호 숨김 라벨을 `줄번호 표시 안함`으로 변경.
+
## v1.8.3
- 코드 블록 줄번호 표시 자동 저장 옵션을 `line-numbers`로 변경.
diff --git a/package-lock.json b/package-lock.json
index 2232f3a..c88c52d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "1.8.3",
+ "version": "1.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "1.8.3",
+ "version": "1.8.4",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index 3b9f3e3..a894740 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.8.3",
+ "version": "1.8.4",
"private": true,
"type": "module",
"imports": {
diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js
index 144b15a..793b020 100644
--- a/server/repositories/content-repository.js
+++ b/server/repositories/content-repository.js
@@ -174,16 +174,24 @@ const normalizeTagSlugs = (tags = []) => {
const out = []
for (const tag of tags) {
- const trimmed = String(tag).trim().normalize('NFC')
- if (!trimmed) {
+ const normalized = String(tag)
+ .trim()
+ .normalize('NFC')
+ .toLowerCase()
+ .replace(/[\\/]+/g, '-')
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9가-힣-]+/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+ if (!normalized) {
continue
}
- const dedupeKey = trimmed.toLowerCase()
+ const dedupeKey = normalized.toLowerCase()
if (seen.has(dedupeKey)) {
continue
}
seen.add(dedupeKey)
- out.push(trimmed)
+ out.push(normalized)
}
return out
@@ -210,14 +218,37 @@ const getTagNameFromSlug = (slug) => {
.join(' ')
}
+/**
+ * 새 태그 표시 이름을 정리한다.
+ * @param {string} value - 원본 표시 이름
+ * @returns {string} 표시 이름
+ */
+const normalizeTagDisplayName = (value) => String(value || '')
+ .normalize('NFC')
+ .trim()
+ .replace(/\s*\/\s*/g, '/')
+ .replace(/\s+/g, ' ')
+
+/**
+ * 태그 슬러그에 대응하는 표시 이름을 반환한다.
+ * @param {string} slug - 태그 슬러그
+ * @param {Object} tagNames - 태그 표시 이름 맵
+ * @returns {string} 태그명
+ */
+const resolveTagName = (slug, tagNames = {}) => {
+ const inputName = normalizeTagDisplayName(tagNames[slug])
+ return inputName || getTagNameFromSlug(slug)
+}
+
/**
* 게시물 태그 연결 저장
* @param {Function} sql - PostgreSQL 트랜잭션 클라이언트
* @param {string} postId - 게시물 ID
* @param {Array} tags - 태그 슬러그 목록
+ * @param {Object} tagNames - 태그 표시 이름 맵
* @returns {Promise} 저장 결과
*/
-const syncPostTags = async (sql, postId, tags) => {
+const syncPostTags = async (sql, postId, tags, tagNames = {}) => {
const tagSlugs = normalizeTagSlugs(tags)
await sql`
@@ -228,7 +259,7 @@ const syncPostTags = async (sql, postId, tags) => {
for (const [index, slug] of tagSlugs.entries()) {
const tagRows = await sql`
INSERT INTO tags (name, slug, tag_type, sort_order)
- VALUES (${getTagNameFromSlug(slug)}, ${slug}, 'general', 0)
+ VALUES (${resolveTagName(slug, tagNames)}, ${slug}, 'general', 0)
ON CONFLICT (slug) DO UPDATE
SET updated_at = now()
RETURNING id
@@ -400,7 +431,7 @@ export const createAdminPost = async (input, authorId) => {
RETURNING *
`
- await syncPostTags(transaction, insertedRows[0].id, input.tags)
+ await syncPostTags(transaction, insertedRows[0].id, input.tags, input.tagNames)
return insertedRows
})
@@ -452,7 +483,7 @@ export const updateAdminPost = async (id, input, editorId) => {
return []
}
- await syncPostTags(transaction, id, input.tags)
+ await syncPostTags(transaction, id, input.tags, input.tagNames)
return updatedRows
})
diff --git a/server/utils/admin-post-input.js b/server/utils/admin-post-input.js
index 95e3f56..7051c97 100644
--- a/server/utils/admin-post-input.js
+++ b/server/utils/admin-post-input.js
@@ -26,7 +26,8 @@ export const adminPostInputSchema = z.object({
ogImage: z.string().trim().nullable().default(null),
status: postStatusSchema.default('draft'),
publishedAt: z.string().datetime().nullable().default(null),
- tags: z.array(z.string().trim().min(1)).default([])
+ tags: z.array(z.string().trim().min(1)).default([]),
+ tagNames: z.record(z.string()).default({})
})
/**