Compare commits
2 Commits
bd71ca860c
...
1d9a3e4527
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d9a3e4527 | |||
| 79fb354d91 |
@@ -122,6 +122,7 @@
|
||||
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
||||
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
||||
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
||||
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
|
||||
|
||||
민감 정보 예시:
|
||||
- 실명
|
||||
|
||||
@@ -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,43 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-12 v0.0.87
|
||||
|
||||
### 저장·로그인 버튼 기본 비활성과 글 목록 삭제 아이콘
|
||||
|
||||
정렬 저장·메뉴 저장은 변경이 없을 때 연속 클릭이 의미 없으므로, 서버 스냅샷과 비교해 dirty일 때만 활성화한다. 로그인은 빈 제출을 막기 위해 필수 필드가 채워진 뒤에만 버튼을 켠다. 글 목록 삭제는 보조 액션이므로 텍스트 강조 대신 휴지통 아이콘과 낮은 기본 대비, 호버 시 강조로 시선 부담을 줄였다.
|
||||
|
||||
## 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
|
||||
|
||||
### 메인 태그는 강등, 일반 태그는 검색 삭제
|
||||
|
||||
14
docs/map.md
14
docs/map.md
@@ -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 | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
@@ -73,17 +73,17 @@
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드 |
|
||||
| pages/admin/login.vue | 관리자 로그인 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시 |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| 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/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||
@@ -102,7 +102,7 @@
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
|
||||
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글, 회원 로그인 API 연동 |
|
||||
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글, 회원 로그인 API 연동, 이메일·비밀번호 입력 시에만 제출 버튼 활성 |
|
||||
| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
|
||||
|
||||
## 서버 API
|
||||
|
||||
20
docs/spec.md
20
docs/spec.md
@@ -403,12 +403,15 @@ components/content/
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
|
||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
@@ -420,7 +423,14 @@ components/content/
|
||||
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- `/` 명령 메뉴의 검색어가 바뀌지 않은 경우에는 현재 강조 인덱스를 유지해 연속 방향키 이동이 가능해야 한다.
|
||||
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
|
||||
- 슬래시 메뉴 방향키 이동 로직은 현재 블록 텍스트가 `/`로 시작할 때만 동작한다.
|
||||
- 슬래시 메뉴는 화면 높이에 맞춰 최대 높이를 제한하고, 넘치는 항목은 내부 스크롤로 표시한다.
|
||||
- 슬래시 메뉴 방향키 이동 시 현재 선택 항목이 스크롤 영역 안에 유지되도록 자동 스크롤한다.
|
||||
- 일반 본문 블록에서는 위/아래 방향키 입력 시 커서가 블록 시작/끝에 도달하면 인접 블록으로 커서를 이동한다.
|
||||
- 관리자 에디터에서 의도적으로 만든 빈 문단은 `<!--sori:blank-paragraph-->` 마커로 저장해 저장/재진입 후에도 유지한다.
|
||||
- 공개 본문 렌더러는 빈 문단 마커를 빈 문단 블록으로 파싱해 문단 간 추가 여백 의도를 유지한다.
|
||||
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
||||
@@ -456,16 +466,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`로 출력한다.
|
||||
@@ -507,6 +520,7 @@ components/content/
|
||||
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다.
|
||||
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다.
|
||||
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다.
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 필드 조합이 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
@@ -514,6 +528,7 @@ components/content/
|
||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
||||
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
||||
- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다.
|
||||
@@ -523,6 +538,7 @@ components/content/
|
||||
### 회원 인증
|
||||
|
||||
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
|
||||
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||
@@ -619,6 +635,6 @@ APP_PORT=43118
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.82
|
||||
- 현재 버전: v0.0.85
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.87
|
||||
|
||||
- 메인 태그 `정렬 저장`·메뉴 `메뉴 저장`은 서버에서 받은 상태와 비교해 변경이 있을 때만 버튼이 활성화되도록 조정.
|
||||
- 관리자 로그인·회원 로그인(`signin`) 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화.
|
||||
- 관리자 글 목록의 삭제는 휴지통 아이콘으로 바꾸고, 기본은 낮은 불투명도·호버 시에만 강조.
|
||||
- `AGENTS.md`에 커밋 후 원격 반영 시 `git push` 생략 방지 지침을 추가.
|
||||
|
||||
## 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.87",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -10,6 +10,12 @@ const form = reactive({
|
||||
|
||||
const pending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
/**
|
||||
* 로그인 제출 가능 여부(이메일·비밀번호가 모두 채워졌는지)
|
||||
* @returns {boolean} 제출 가능 여부
|
||||
*/
|
||||
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
@@ -83,9 +89,9 @@ const submitLogin = async () => {
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<button
|
||||
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="pending"
|
||||
:disabled="pending || !canSubmitAdminLogin"
|
||||
>
|
||||
{{ pending ? '확인 중' : '로그인' }}
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,28 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
||||
|
||||
const items = ref(navigationItems.value.map((item) => ({ ...item })))
|
||||
|
||||
/**
|
||||
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
|
||||
* @param {Array<Object>} list - 항목 목록
|
||||
* @returns {string} 비교용 JSON 문자열
|
||||
*/
|
||||
const serializeNavigationItems = (list) => JSON.stringify(list.map((item) => ({
|
||||
label: String(item.label || '').trim(),
|
||||
url: String(item.url || '').trim(),
|
||||
location: item.location,
|
||||
sortOrder: Number(item.sortOrder || 0),
|
||||
isVisible: Boolean(item.isVisible)
|
||||
})))
|
||||
|
||||
/** 서버에서 불러온 네비게이션 직렬화 스냅샷 */
|
||||
const navigationBaseline = ref(serializeNavigationItems(items.value))
|
||||
|
||||
/**
|
||||
* 현재 편집본이 서버 스냅샷과 다른지 여부
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !== navigationBaseline.value)
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
@@ -58,6 +80,10 @@ const removeNavigationItem = (index) => {
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveNavigation = async () => {
|
||||
if (saving.value || !isNavigationDirty.value) {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '네비게이션을 저장하는 중입니다.')
|
||||
@@ -77,6 +103,7 @@ const saveNavigation = async () => {
|
||||
})
|
||||
|
||||
items.value = savedItems.map((item) => ({ ...item }))
|
||||
navigationBaseline.value = serializeNavigationItems(items.value)
|
||||
showToast('success', '네비게이션이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.'
|
||||
@@ -184,7 +211,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
:disabled="saving || !isNavigationDirty"
|
||||
>
|
||||
{{ saving ? '저장 중' : '메뉴 저장' }}
|
||||
</button>
|
||||
|
||||
@@ -148,12 +148,23 @@ const deletePost = async (post) => {
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<button
|
||||
class="admin-posts__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
class="admin-posts__delete-icon inline-flex size-9 items-center justify-center rounded text-muted opacity-35 transition-all hover:opacity-100 hover:text-red-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-20"
|
||||
type="button"
|
||||
:disabled="deletingId === post.id"
|
||||
:aria-label="deletingId === post.id ? '삭제 중' : '삭제'"
|
||||
@click="deletePost(post)"
|
||||
>
|
||||
{{ deletingId === post.id ? '삭제 중' : '삭제' }}
|
||||
<span v-if="deletingId === post.id" class="admin-posts__delete-progress text-[10px] font-semibold text-muted" aria-hidden="true">…</span>
|
||||
<svg
|
||||
v-else
|
||||
class="size-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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,61 @@ const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
||||
|
||||
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
||||
|
||||
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */
|
||||
const baselineManagedTagIds = ref([])
|
||||
|
||||
/**
|
||||
* 서버와 동기화된 메인 태그 순서를 기준선으로 저장한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetManagedOrderBaseline = () => {
|
||||
baselineManagedTagIds.value = managedTags.value.map((tag) => tag.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 목록을 다시 불러온 뒤 메인 태그 순서 기준선을 맞춘다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const refreshTagsFromServer = async () => {
|
||||
await refresh()
|
||||
resetManagedOrderBaseline()
|
||||
}
|
||||
|
||||
resetManagedOrderBaseline()
|
||||
|
||||
/**
|
||||
* 메인 태그 드래그 순서가 기준선과 다른지 여부
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const isManagedOrderDirty = computed(() => {
|
||||
const currentIds = managedTags.value.map((tag) => tag.id)
|
||||
const base = baselineManagedTagIds.value
|
||||
|
||||
if (!currentIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentIds.length !== base.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
return currentIds.some((id, index) => id !== base[index])
|
||||
})
|
||||
|
||||
/**
|
||||
* 피드백 토스트 표시
|
||||
* @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 - 드래그 이벤트
|
||||
@@ -100,13 +155,11 @@ const handleDrop = (event, targetId) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveManagedOrder = async () => {
|
||||
if (savingOrder.value || managedTags.value.length === 0) {
|
||||
if (savingOrder.value || managedTags.value.length === 0 || !isManagedOrderDirty.value) {
|
||||
return
|
||||
}
|
||||
|
||||
savingOrder.value = true
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
const reordered = await $fetch('/admin/api/tags/reorder', {
|
||||
@@ -117,10 +170,10 @@ const saveManagedOrder = async () => {
|
||||
})
|
||||
|
||||
tags.value = [...reordered]
|
||||
await refresh()
|
||||
infoMessage.value = '메인 태그 순서가 저장되었습니다.'
|
||||
await refreshTagsFromServer()
|
||||
showToast('success', '메인 태그 순서가 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '정렬 순서를 저장하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
|
||||
} finally {
|
||||
savingOrder.value = false
|
||||
}
|
||||
@@ -138,8 +191,6 @@ const searchGeneralTags = async () => {
|
||||
}
|
||||
|
||||
generalTagSearchLoading.value = true
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
|
||||
@@ -150,7 +201,7 @@ const searchGeneralTags = async () => {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '일반 태그 검색에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.')
|
||||
} finally {
|
||||
generalTagSearchLoading.value = false
|
||||
}
|
||||
@@ -167,8 +218,6 @@ const promoteToMainTag = async (tag) => {
|
||||
}
|
||||
|
||||
promotingTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
@@ -182,11 +231,11 @@ const promoteToMainTag = async (tag) => {
|
||||
tagType: 'managed'
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
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 +252,6 @@ const demoteToGeneralTag = async (tag) => {
|
||||
}
|
||||
|
||||
demotingTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
@@ -218,11 +265,11 @@ const demoteToGeneralTag = async (tag) => {
|
||||
tagType: 'general'
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
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,23 +286,25 @@ const deleteGeneralTag = async (tag) => {
|
||||
}
|
||||
|
||||
deletingGeneralTagId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
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,20 +326,13 @@ 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>
|
||||
<button
|
||||
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="savingOrder || managedTags.length === 0"
|
||||
:disabled="savingOrder || managedTags.length === 0 || !isManagedOrderDirty"
|
||||
@click="saveManagedOrder"
|
||||
>
|
||||
{{ savingOrder ? '저장 중' : '정렬 저장' }}
|
||||
@@ -413,5 +455,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>
|
||||
|
||||
@@ -13,6 +13,12 @@ const form = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 로그인 제출 가능 여부
|
||||
* @returns {boolean} 제출 가능 여부
|
||||
*/
|
||||
const canSubmitSignIn = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
|
||||
/**
|
||||
* 로그인 입력값을 검증한다.
|
||||
* @returns {boolean} 검증 통과 여부
|
||||
@@ -99,9 +105,9 @@ const submitSignIn = async () => {
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
:disabled="isSubmitting || !canSubmitSignIn"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
|
||||
@@ -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