v0.0.86: 미리보기 패딩, 태그 한글 유지, SEO 자동, 태그 관리 토스트
This commit is contained in:
@@ -96,8 +96,6 @@ const form = reactive({
|
||||
excerpt: props.initialPost.excerpt || '',
|
||||
content: props.initialPost.content || '',
|
||||
featuredImage: props.initialPost.featuredImage || '',
|
||||
seoTitle: props.initialPost.seoTitle || '',
|
||||
seoDescription: props.initialPost.seoDescription || '',
|
||||
noindex: Boolean(props.initialPost.noindex),
|
||||
status: props.initialPost.status || 'draft',
|
||||
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
||||
@@ -150,6 +148,24 @@ const toSlug = (value) => value
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
/**
|
||||
* 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로)
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 정규화된 태그 문자열
|
||||
*/
|
||||
const normalizeTagToken = (value) => {
|
||||
const raw = String(value).normalize('NFC').trim().toLowerCase()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return raw
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9가-힣-]+/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
watch(() => form.title, (title) => {
|
||||
if (!slugTouched.value) {
|
||||
form.slug = toSlug(title)
|
||||
@@ -166,14 +182,29 @@ const touchSlug = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 쉼표 구분 태그 문자열을 슬러그 배열로 변환
|
||||
* 쉼표 구분 태그 문자열을 토큰 배열로 변환
|
||||
* @param {string} value - 태그 입력 문자열
|
||||
* @returns {Array<string>} 태그 슬러그 목록
|
||||
* @returns {Array<string>} 태그 토큰 목록
|
||||
*/
|
||||
const parseTags = (value) => [...new Set(value
|
||||
.split(',')
|
||||
.map((tag) => toSlug(tag))
|
||||
.filter(Boolean))]
|
||||
const parseTags = (value) => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
|
||||
for (const part of value.split(',')) {
|
||||
const tag = normalizeTagToken(part)
|
||||
if (!tag) {
|
||||
continue
|
||||
}
|
||||
const dedupeKey = tag.toLowerCase()
|
||||
if (seen.has(dedupeKey)) {
|
||||
continue
|
||||
}
|
||||
seen.add(dedupeKey)
|
||||
out.push(tag)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const selectedTags = computed(() => parseTags(form.tagsText))
|
||||
|
||||
@@ -218,8 +249,8 @@ const createPostPayload = () => {
|
||||
excerpt: form.excerpt.trim(),
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage.trim() || null,
|
||||
seoTitle: form.seoTitle.trim(),
|
||||
seoDescription: form.seoDescription.trim(),
|
||||
seoTitle: form.title.trim(),
|
||||
seoDescription: form.excerpt.trim(),
|
||||
canonicalUrl: '',
|
||||
noindex: form.noindex,
|
||||
ogImage: null,
|
||||
@@ -239,8 +270,6 @@ const createAutosavePayload = () => ({
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage,
|
||||
seoTitle: form.seoTitle,
|
||||
seoDescription: form.seoDescription,
|
||||
noindex: form.noindex,
|
||||
status: form.status,
|
||||
publishedAt: form.publishedAt,
|
||||
@@ -258,8 +287,6 @@ const isEmptyAutosavePayload = (payload) => ![
|
||||
payload.excerpt,
|
||||
payload.content,
|
||||
payload.featuredImage,
|
||||
payload.seoTitle,
|
||||
payload.seoDescription,
|
||||
payload.tagsText
|
||||
].some((value) => String(value || '').trim())
|
||||
|
||||
@@ -416,7 +443,7 @@ const removeFeaturedImage = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const addTagFromInput = () => {
|
||||
const nextTag = toSlug(tagInput.value)
|
||||
const nextTag = normalizeTagToken(tagInput.value)
|
||||
|
||||
if (!nextTag) {
|
||||
tagInput.value = ''
|
||||
@@ -794,43 +821,16 @@ defineExpose({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__seo grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<div class="admin-post-form__search-visibility grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<div>
|
||||
<h2 class="admin-post-form__section-title text-sm font-semibold text-ink">
|
||||
SEO
|
||||
검색 노출
|
||||
</h2>
|
||||
<p class="admin-post-form__section-description mt-1 text-xs text-muted">
|
||||
검색 결과와 공유 미리보기에 사용할 기본 메타 정보를 설정합니다.
|
||||
메타 제목·설명은 저장 시 글 제목과 요약을 그대로 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2">
|
||||
<span class="admin-post-form__label font-medium">SEO 제목</span>
|
||||
<input
|
||||
v-model="form.seoTitle"
|
||||
class="admin-post-form__input h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
type="text"
|
||||
maxlength="80"
|
||||
placeholder="비워두면 글 제목을 사용"
|
||||
>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ form.seoTitle.length }}/80
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2">
|
||||
<span class="admin-post-form__label font-medium">SEO 설명</span>
|
||||
<textarea
|
||||
v-model="form.seoDescription"
|
||||
class="admin-post-form__textarea min-h-[108px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
maxlength="180"
|
||||
placeholder="비워두면 요약을 사용"
|
||||
/>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ form.seoDescription.length }}/180
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__checkbox flex items-start gap-2 text-sm">
|
||||
<input
|
||||
v-model="form.noindex"
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-12 v0.0.86
|
||||
|
||||
### 게시물 URL 로마자화와 태그 표기 분리
|
||||
|
||||
게시물 슬러그는 URL 안정성을 위해 한글 음절을 로마자로 바꾸는 기존 방식을 유지한다. 반면 태그는 사용자가 입력한 한글을 그대로 쓰는 경우가 많고, 동일 로마자화를 적용하면 배지·DB `name`이 기대와 달라지므로 태그 토큰은 한글·영문·숫자와 하이픈만 정리하는 별도 정규화로 분리했다. 저장소에서는 태그 슬러그에 한글이 포함되면 하이픈을 공백으로 바꾼 문자열을 표시명으로 쓰고, 순수 라틴 하이픈 슬러그는 기존처럼 단어별 이니셜 대문자 규칙을 유지한다.
|
||||
|
||||
### 관리자 글 SEO 입력 단순화
|
||||
|
||||
공개 상세는 이미 SEO 필드가 비어 있으면 제목·요약을 메타 기본값으로 쓰므로, 관리자 폼에서 별도 SEO 제목·설명 입력을 두면 중복 편집만 늘어난다. 저장 시점에 제목·요약을 `seo_title`·`seo_description`에 동기화하고 폼에서는 `noindex`만 노출해 입력 부담을 줄였다.
|
||||
|
||||
### 태그 관리 피드백을 토스트로
|
||||
|
||||
순서 저장 등 성공 메시지를 본문 위에 블록으로 넣으면 레이아웃이 밀려 체감 품질이 떨어지므로, 네비게이션 저장과 동일하게 우측 상단 고정 토스트로 통일했다.
|
||||
|
||||
## 2026-05-11 v0.0.85
|
||||
|
||||
### 의도한 빈 문단 저장 보존
|
||||
|
||||
블록 에디터는 마지막 보조 빈 문단을 자동으로 유지하는 구조라서, 저장 시 모든 빈 문단을 제거하면 사용자가 의도적으로 만든 2~3줄 공백도 함께 사라진다. 이를 구분하기 위해 빈 문단 전용 마커를 저장 포맷에 도입하고, 에디터 파서와 공개 렌더러 파서가 동일하게 해석하도록 맞춰 공백 의도를 보존했다.
|
||||
|
||||
## 2026-05-11 v0.0.84
|
||||
|
||||
### 방향키 문단 이동과 슬래시 메뉴 스크롤 고정
|
||||
|
||||
관리자 에디터는 블록 단위 편집이므로 일반 텍스트 에디터처럼 위/아래 방향키로 인접 문단으로 자연스럽게 넘어가야 한다. 커서가 블록 경계에 있을 때만 인접 블록으로 이동하도록 보완해 기존 블록 내부 이동과 충돌을 줄였다. 슬래시 메뉴는 명령 수가 많아도 화면을 넘기지 않도록 최대 높이+내부 스크롤로 제한하고, 방향키 하이라이트 항목을 항상 가시 영역으로 자동 스크롤해 선택 맥락을 유지하도록 정리했다.
|
||||
|
||||
## 2026-05-11 v0.0.83
|
||||
|
||||
### 슬래시 메뉴 방향키 상태 유지
|
||||
|
||||
슬래시 메뉴 강조 인덱스를 검색어 동기화 때마다 0으로 초기화하면 방향키를 여러 번 눌러도 체감상 1회만 이동하는 것처럼 보이므로, 검색어가 바뀐 경우에만 초기화하도록 분리했다. 또한 슬래시 입력 상태가 아닌 일반 본문 블록에서는 메뉴 방향키 핸들러를 즉시 빠져나오게 해 기본 커서 이동 동작을 최대한 유지하도록 조정했다.
|
||||
|
||||
## 2026-05-11 v0.0.82
|
||||
|
||||
### 메인 태그는 강등, 일반 태그는 검색 삭제
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
@@ -77,13 +77,13 @@
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시 |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| 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 | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||
|
||||
15
docs/spec.md
15
docs/spec.md
@@ -409,6 +409,7 @@ components/content/
|
||||
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
@@ -420,7 +421,14 @@ components/content/
|
||||
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- `/` 명령 메뉴의 검색어가 바뀌지 않은 경우에는 현재 강조 인덱스를 유지해 연속 방향키 이동이 가능해야 한다.
|
||||
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
|
||||
- 슬래시 메뉴 방향키 이동 로직은 현재 블록 텍스트가 `/`로 시작할 때만 동작한다.
|
||||
- 슬래시 메뉴는 화면 높이에 맞춰 최대 높이를 제한하고, 넘치는 항목은 내부 스크롤로 표시한다.
|
||||
- 슬래시 메뉴 방향키 이동 시 현재 선택 항목이 스크롤 영역 안에 유지되도록 자동 스크롤한다.
|
||||
- 일반 본문 블록에서는 위/아래 방향키 입력 시 커서가 블록 시작/끝에 도달하면 인접 블록으로 커서를 이동한다.
|
||||
- 관리자 에디터에서 의도적으로 만든 빈 문단은 `<!--sori:blank-paragraph-->` 마커로 저장해 저장/재진입 후에도 유지한다.
|
||||
- 공개 본문 렌더러는 빈 문단 마커를 빈 문단 블록으로 파싱해 문단 간 추가 여백 의도를 유지한다.
|
||||
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
||||
@@ -456,16 +464,19 @@ components/content/
|
||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||
- 미리보기 본문·헤로 영역은 공개 상세와 동일하게 중앙 `max-w-[720px]` 컬럼과 `px-4 sm:px-5` 수평 패딩을 적용한다.
|
||||
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
||||
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
|
||||
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
||||
- 태그 토큰은 게시물 URL용 `toSlug`(한글 로마자화)와 분리하여 한글을 유지하고, 공백은 하이픈으로만 정리하며 `a-z0-9가-힣` 및 하이픈만 허용한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
||||
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
||||
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
||||
- 글 SEO 설정은 SEO 제목, SEO 설명, 검색엔진 노출 제외 여부를 저장한다.
|
||||
- 글 SEO 메타(`seo_title`, `seo_description`)는 별도 입력 없이 저장 시 글 제목·요약과 동일하게 기록한다.
|
||||
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
|
||||
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
@@ -619,6 +630,6 @@ APP_PORT=43118
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.82
|
||||
- 현재 버전: v0.0.85
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.86
|
||||
|
||||
- 관리자 게시물 미리보기 본문 영역을 공개 상세와 동일한 `max-w-[720px]`·좌우 패딩으로 감싸 여백을 맞춤.
|
||||
- 글 작성 폼에서 태그 토큰은 로마자 슬러그 변환 대신 한글 유지 정규화를 사용하고, 저장 시 태그 `name`은 한글 슬러그에 맞게 표시되도록 저장소 `normalizeTagSlugs`·`getTagNameFromSlug`를 조정.
|
||||
- 관리자 게시물 폼에서 SEO 제목·설명 입력을 제거하고 저장 시 제목·요약을 메타 필드로 기록하도록 변경. `admin-post-input`의 SEO 문자열 길이 제한을 완화.
|
||||
- 관리자 태그 관리 화면의 성공·오류 안내를 본문 상단 블록 대신 우측 상단 고정 토스트로 표시.
|
||||
|
||||
## v0.0.85
|
||||
|
||||
- 관리자 블록 에디터 저장 시 의도적으로 만든 빈 문단(연속 Enter)을 제거하지 않도록 빈 문단 마커(`<!--sori:blank-paragraph-->`) 직렬화/복원 로직을 추가.
|
||||
- 공개 본문 마크다운 렌더러에서도 빈 문단 마커를 문단 블록으로 해석해 저장 후에도 문단 간 여백 의도를 유지하도록 맞춤.
|
||||
|
||||
## v0.0.84
|
||||
|
||||
- 관리자 블록 에디터 본문에서 위/아래 방향키 입력 시 커서가 블록 시작/끝 경계에 있으면 이전/다음 문단(또는 구조형 블록)으로 이동하도록 보완.
|
||||
- 슬래시 메뉴를 최대 높이 제한 + 내부 스크롤 구조로 변경해 명령 개수가 많아도 화면을 넘기지 않도록 조정.
|
||||
- 슬래시 메뉴 방향키 이동 시 현재 하이라이트 항목이 항상 스크롤 영역 안에 보이도록 자동 스크롤을 추가.
|
||||
|
||||
## v0.0.83
|
||||
|
||||
- 관리자 블록 에디터 슬래시 메뉴에서 방향키 이동 시 하이라이트 인덱스가 매번 초기화되던 문제를 수정해 연속 이동이 가능하도록 보정.
|
||||
- 슬래시 메뉴가 열린 블록(`text`가 `/`로 시작)에서만 위/아래 방향키 메뉴 이동 로직이 동작하도록 분기해, 일반 본문 블록의 방향키 입력 간섭을 줄임.
|
||||
|
||||
## v0.0.82
|
||||
|
||||
- 메인 태그 목록의 `삭제` 버튼을 제거하고 `일반 태그로 변경`(강등) 버튼으로 교체.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.79",
|
||||
"version": "0.0.86",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -34,21 +34,25 @@ onMounted(loadPreviewPost)
|
||||
관리자 미리보기
|
||||
</div>
|
||||
|
||||
<p v-if="previewError" class="admin-post-preview__error rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<p v-if="previewError" class="admin-post-preview__error rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 sm:mx-auto sm:max-w-[720px] sm:px-5">
|
||||
{{ previewError }}
|
||||
</p>
|
||||
|
||||
<ContentRenderer v-else-if="previewPost">
|
||||
<ProseHeaderCard>
|
||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||
PREVIEW
|
||||
</p>
|
||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ previewPost.title || '제목 없음' }}
|
||||
</h1>
|
||||
</ProseHeaderCard>
|
||||
<section v-else-if="previewPost" class="admin-post-preview__body">
|
||||
<div class="mx-auto max-w-[720px] px-4 sm:px-5">
|
||||
<ContentRenderer>
|
||||
<ProseHeaderCard>
|
||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||
PREVIEW
|
||||
</p>
|
||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ previewPost.title || '제목 없음' }}
|
||||
</h1>
|
||||
</ProseHeaderCard>
|
||||
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="previewPost.content || ''" />
|
||||
</ContentRenderer>
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="previewPost.content || ''" />
|
||||
</ContentRenderer>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,8 +9,8 @@ const savingOrder = ref(false)
|
||||
const promotingTagId = ref('')
|
||||
const demotingTagId = ref('')
|
||||
const deletingGeneralTagId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const infoMessage = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
const generalTagQuery = ref('')
|
||||
const generalTagSearchResults = ref([])
|
||||
const generalTagSearchLoading = ref(false)
|
||||
@@ -21,6 +21,20 @@ const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
||||
|
||||
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
||||
|
||||
/**
|
||||
* 피드백 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 시작
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
@@ -105,8 +119,6 @@ const saveManagedOrder = async () => {
|
||||
}
|
||||
|
||||
savingOrder.value = true
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
const reordered = await $fetch('/admin/api/tags/reorder', {
|
||||
@@ -118,9 +130,9 @@ const saveManagedOrder = async () => {
|
||||
|
||||
tags.value = [...reordered]
|
||||
await refresh()
|
||||
infoMessage.value = '메인 태그 순서가 저장되었습니다.'
|
||||
showToast('success', '메인 태그 순서가 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '정렬 순서를 저장하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
|
||||
} finally {
|
||||
savingOrder.value = false
|
||||
}
|
||||
@@ -138,8 +150,6 @@ const searchGeneralTags = async () => {
|
||||
}
|
||||
|
||||
generalTagSearchLoading.value = true
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
|
||||
@@ -150,7 +160,7 @@ const searchGeneralTags = async () => {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '일반 태그 검색에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.')
|
||||
} finally {
|
||||
generalTagSearchLoading.value = false
|
||||
}
|
||||
@@ -167,8 +177,6 @@ const promoteToMainTag = async (tag) => {
|
||||
}
|
||||
|
||||
promotingTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
@@ -184,9 +192,9 @@ const promoteToMainTag = async (tag) => {
|
||||
})
|
||||
await refresh()
|
||||
await searchGeneralTags()
|
||||
infoMessage.value = `"${tag.name}" 태그를 메인 태그로 전환했습니다.`
|
||||
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '메인 태그 전환에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
|
||||
} finally {
|
||||
promotingTagId.value = ''
|
||||
}
|
||||
@@ -203,8 +211,6 @@ const demoteToGeneralTag = async (tag) => {
|
||||
}
|
||||
|
||||
demotingTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
@@ -220,9 +226,9 @@ const demoteToGeneralTag = async (tag) => {
|
||||
})
|
||||
await refresh()
|
||||
await searchGeneralTags()
|
||||
infoMessage.value = `"${tag.name}" 태그를 일반 태그로 변경했습니다.`
|
||||
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '일반 태그 변경에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
|
||||
} finally {
|
||||
demotingTagId.value = ''
|
||||
}
|
||||
@@ -239,8 +245,6 @@ const deleteGeneralTag = async (tag) => {
|
||||
}
|
||||
|
||||
deletingGeneralTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
@@ -248,14 +252,18 @@ const deleteGeneralTag = async (tag) => {
|
||||
})
|
||||
await refresh()
|
||||
await searchGeneralTags()
|
||||
infoMessage.value = `"${tag.name}" 일반 태그를 삭제했습니다.`
|
||||
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '일반 태그를 삭제하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
|
||||
} finally {
|
||||
deletingGeneralTagId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -277,13 +285,6 @@ const deleteGeneralTag = async (tag) => {
|
||||
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 수 있습니다.
|
||||
</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">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<p v-if="infoMessage" class="mt-3 rounded border border-line bg-white px-4 py-3 text-sm text-muted">
|
||||
{{ infoMessage }}
|
||||
</p>
|
||||
|
||||
<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>
|
||||
@@ -413,5 +414,18 @@ const deleteGeneralTag = async (tag) => {
|
||||
<p v-if="tags.length === 0" class="admin-tags__empty mt-6 text-sm text-muted">
|
||||
아직 등록된 태그가 없습니다.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-tags__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -98,20 +98,46 @@ const mapNavigationItemRow = (row) => ({
|
||||
* @param {Array<string>} tags - 태그 슬러그 목록
|
||||
* @returns {Array<string>} 정규화된 태그 슬러그 목록
|
||||
*/
|
||||
const normalizeTagSlugs = (tags = []) => [...new Set(tags
|
||||
.map((tag) => String(tag).trim().toLowerCase())
|
||||
.filter(Boolean))]
|
||||
const normalizeTagSlugs = (tags = []) => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
|
||||
for (const tag of tags) {
|
||||
const trimmed = String(tag).trim().normalize('NFC')
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
const dedupeKey = trimmed.toLowerCase()
|
||||
if (seen.has(dedupeKey)) {
|
||||
continue
|
||||
}
|
||||
seen.add(dedupeKey)
|
||||
out.push(trimmed)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 슬러그를 태그명으로 변환
|
||||
* 태그 슬러그를 표시용 태그명으로 변환
|
||||
* @param {string} slug - 태그 슬러그
|
||||
* @returns {string} 태그명
|
||||
*/
|
||||
const getTagNameFromSlug = (slug) => slug
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
|
||||
.join(' ')
|
||||
const getTagNameFromSlug = (slug) => {
|
||||
const trimmed = String(slug).trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
if (/[가-힣]/.test(trimmed)) {
|
||||
return trimmed.split('-').filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 태그 연결 저장
|
||||
|
||||
@@ -7,8 +7,8 @@ export const adminPostInputSchema = z.object({
|
||||
content: z.string().default(''),
|
||||
excerpt: z.string().default(''),
|
||||
featuredImage: z.string().trim().nullable().default(null),
|
||||
seoTitle: z.string().trim().max(80).default(''),
|
||||
seoDescription: z.string().trim().max(180).default(''),
|
||||
seoTitle: z.string().trim().default(''),
|
||||
seoDescription: z.string().trim().default(''),
|
||||
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
|
||||
noindex: z.boolean().default(false),
|
||||
ogImage: z.string().trim().nullable().default(null),
|
||||
|
||||
Reference in New Issue
Block a user