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({}) }) /**