태그 슬러그와 코드 라벨 개선
This commit is contained in:
@@ -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
|
||||
|
||||
/**
|
||||
* 태그 슬러그의 고유 색상을 반환한다.
|
||||
|
||||
@@ -138,7 +138,7 @@ const toggleLineNumbers = () => {
|
||||
@mousedown.prevent
|
||||
@click="toggleLineNumbers"
|
||||
>
|
||||
{{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
|
||||
{{ lineNumbersEnabled ? '줄번호' : '줄번호 표시 안함' }}
|
||||
</button>
|
||||
<input
|
||||
v-model="languageDraft"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.8.4
|
||||
|
||||
- 글쓰기에서 새 태그를 추가할 때 배지에 슬러그 대신 입력한 이름을 보여주도록 수정했다.
|
||||
- `Xenoblade3/Hero`처럼 `/`가 들어간 태그는 이름은 유지하고 슬러그에서는 `-`로 저장한다.
|
||||
- 라이브 코드 블록의 줄번호 숨김 라벨을 `줄번호 표시 안함`으로 바꿨다.
|
||||
|
||||
## v1.8.3
|
||||
|
||||
- 코드 블록 설정에서 줄번호 표시를 켠 경우 저장되는 옵션명을 `line-numbers`로 바꿨다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 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 마이그레이션은 없다.
|
||||
|
||||
@@ -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·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
|
||||
|
||||
@@ -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` - 로그인
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.8.4
|
||||
|
||||
- 게시물 글쓰기에서 새 태그 배지에 슬러그 대신 입력한 태그명을 표시하도록 수정.
|
||||
- 새 태그 저장 시 입력한 태그명을 유지하고 `/`는 슬러그에서 `-`로 변환하도록 수정.
|
||||
- 라이브 코드 블록의 줄번호 숨김 라벨을 `줄번호 표시 안함`으로 변경.
|
||||
|
||||
## v1.8.3
|
||||
|
||||
- 코드 블록 줄번호 표시 자동 저장 옵션을 `line-numbers`로 변경.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.8.3",
|
||||
"version": "1.8.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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<string>} tags - 태그 슬러그 목록
|
||||
* @param {Object} tagNames - 태그 표시 이름 맵
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user