v0.0.87: 저장·로그인 버튼 비활성 기본, 글 목록 삭제 아이콘, 푸시 지침
This commit is contained in:
@@ -122,6 +122,7 @@
|
|||||||
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
||||||
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
||||||
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
||||||
|
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
|
||||||
|
|
||||||
민감 정보 예시:
|
민감 정보 예시:
|
||||||
- 실명
|
- 실명
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.87
|
||||||
|
|
||||||
|
### 저장·로그인 버튼 기본 비활성과 글 목록 삭제 아이콘
|
||||||
|
|
||||||
|
정렬 저장·메뉴 저장은 변경이 없을 때 연속 클릭이 의미 없으므로, 서버 스냅샷과 비교해 dirty일 때만 활성화한다. 로그인은 빈 제출을 막기 위해 필수 필드가 채워진 뒤에만 버튼을 켠다. 글 목록 삭제는 보조 액션이므로 텍스트 강조 대신 휴지통 아이콘과 낮은 기본 대비, 호버 시 강조로 시선 부담을 줄였다.
|
||||||
|
|
||||||
## 2026-05-12 v0.0.86
|
## 2026-05-12 v0.0.86
|
||||||
|
|
||||||
### 게시물 URL 로마자화와 태그 표기 분리
|
### 게시물 URL 로마자화와 태그 표기 분리
|
||||||
|
|||||||
10
docs/map.md
10
docs/map.md
@@ -73,8 +73,8 @@
|
|||||||
| 파일 | 화면 |
|
| 파일 | 화면 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pages/admin/index.vue | 대시보드 |
|
| pages/admin/index.vue | 대시보드 |
|
||||||
| pages/admin/login.vue | 관리자 로그인 |
|
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시 |
|
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||||
@@ -82,8 +82,8 @@
|
|||||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||||
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 |
|
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 |
|
||||||
| pages/admin/navigation/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/new.vue | 태그 생성 |
|
||||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
||||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||||
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
|
| 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 | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
|
| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
|
||||||
|
|
||||||
## 서버 API
|
## 서버 API
|
||||||
|
|||||||
@@ -403,10 +403,12 @@ components/content/
|
|||||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||||
|
|
||||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||||
|
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||||
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||||
|
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
|
||||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
||||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||||
@@ -518,6 +520,7 @@ components/content/
|
|||||||
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다.
|
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다.
|
||||||
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다.
|
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다.
|
||||||
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다.
|
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다.
|
||||||
|
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 필드 조합이 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||||
|
|
||||||
### 관리자 인증
|
### 관리자 인증
|
||||||
|
|
||||||
@@ -525,6 +528,7 @@ components/content/
|
|||||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다.
|
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다.
|
||||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||||
|
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
||||||
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
||||||
- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다.
|
- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다.
|
||||||
@@ -534,6 +538,7 @@ components/content/
|
|||||||
### 회원 인증
|
### 회원 인증
|
||||||
|
|
||||||
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
|
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
|
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
|
||||||
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.87
|
||||||
|
|
||||||
|
- 메인 태그 `정렬 저장`·메뉴 `메뉴 저장`은 서버에서 받은 상태와 비교해 변경이 있을 때만 버튼이 활성화되도록 조정.
|
||||||
|
- 관리자 로그인·회원 로그인(`signin`) 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화.
|
||||||
|
- 관리자 글 목록의 삭제는 휴지통 아이콘으로 바꾸고, 기본은 낮은 불투명도·호버 시에만 강조.
|
||||||
|
- `AGENTS.md`에 커밋 후 원격 반영 시 `git push` 생략 방지 지침을 추가.
|
||||||
|
|
||||||
## v0.0.86
|
## v0.0.86
|
||||||
|
|
||||||
- 관리자 게시물 미리보기 본문 영역을 공개 상세와 동일한 `max-w-[720px]`·좌우 패딩으로 감싸 여백을 맞춤.
|
- 관리자 게시물 미리보기 본문 영역을 공개 상세와 동일한 `max-w-[720px]`·좌우 패딩으로 감싸 여백을 맞춤.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.86",
|
"version": "0.0.87",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ const form = reactive({
|
|||||||
|
|
||||||
const pending = ref(false)
|
const pending = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 제출 가능 여부(이메일·비밀번호가 모두 채워졌는지)
|
||||||
|
* @returns {boolean} 제출 가능 여부
|
||||||
|
*/
|
||||||
|
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||||
default: () => ({
|
default: () => ({
|
||||||
hasUsers: true,
|
hasUsers: true,
|
||||||
@@ -83,9 +89,9 @@ const submitLogin = async () => {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
type="submit"
|
||||||
:disabled="pending"
|
:disabled="pending || !canSubmitAdminLogin"
|
||||||
>
|
>
|
||||||
{{ pending ? '확인 중' : '로그인' }}
|
{{ pending ? '확인 중' : '로그인' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
|||||||
|
|
||||||
const items = ref(navigationItems.value.map((item) => ({ ...item })))
|
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 - 토스트 타입
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
@@ -58,6 +80,10 @@ const removeNavigationItem = (index) => {
|
|||||||
* @returns {Promise<void>} 저장 결과
|
* @returns {Promise<void>} 저장 결과
|
||||||
*/
|
*/
|
||||||
const saveNavigation = async () => {
|
const saveNavigation = async () => {
|
||||||
|
if (saving.value || !isNavigationDirty.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
showToast('info', '네비게이션을 저장하는 중입니다.')
|
showToast('info', '네비게이션을 저장하는 중입니다.')
|
||||||
@@ -77,6 +103,7 @@ const saveNavigation = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
items.value = savedItems.map((item) => ({ ...item }))
|
items.value = savedItems.map((item) => ({ ...item }))
|
||||||
|
navigationBaseline.value = serializeNavigationItems(items.value)
|
||||||
showToast('success', '네비게이션이 저장되었습니다.')
|
showToast('success', '네비게이션이 저장되었습니다.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.'
|
||||||
@@ -184,7 +211,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="saving"
|
:disabled="saving || !isNavigationDirty"
|
||||||
>
|
>
|
||||||
{{ saving ? '저장 중' : '메뉴 저장' }}
|
{{ saving ? '저장 중' : '메뉴 저장' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -148,12 +148,23 @@ const deletePost = async (post) => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="admin-posts__cell px-4 py-4">
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
<button
|
<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"
|
type="button"
|
||||||
:disabled="deletingId === post.id"
|
:disabled="deletingId === post.id"
|
||||||
|
:aria-label="deletingId === post.id ? '삭제 중' : '삭제'"
|
||||||
@click="deletePost(post)"
|
@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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -21,6 +21,47 @@ const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
|||||||
|
|
||||||
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
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 {'success'|'error'|'info'} type - 토스트 타입
|
||||||
@@ -114,7 +155,7 @@ const handleDrop = (event, targetId) => {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const saveManagedOrder = async () => {
|
const saveManagedOrder = async () => {
|
||||||
if (savingOrder.value || managedTags.value.length === 0) {
|
if (savingOrder.value || managedTags.value.length === 0 || !isManagedOrderDirty.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +170,7 @@ const saveManagedOrder = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
tags.value = [...reordered]
|
tags.value = [...reordered]
|
||||||
await refresh()
|
await refreshTagsFromServer()
|
||||||
showToast('success', '메인 태그 순서가 저장되었습니다.')
|
showToast('success', '메인 태그 순서가 저장되었습니다.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
|
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
|
||||||
@@ -190,7 +231,7 @@ const promoteToMainTag = async (tag) => {
|
|||||||
tagType: 'managed'
|
tagType: 'managed'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await refresh()
|
await refreshTagsFromServer()
|
||||||
await searchGeneralTags()
|
await searchGeneralTags()
|
||||||
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -224,7 +265,7 @@ const demoteToGeneralTag = async (tag) => {
|
|||||||
tagType: 'general'
|
tagType: 'general'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await refresh()
|
await refreshTagsFromServer()
|
||||||
await searchGeneralTags()
|
await searchGeneralTags()
|
||||||
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -250,7 +291,7 @@ const deleteGeneralTag = async (tag) => {
|
|||||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
await refresh()
|
await refreshTagsFromServer()
|
||||||
await searchGeneralTags()
|
await searchGeneralTags()
|
||||||
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -291,7 +332,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="savingOrder || managedTags.length === 0"
|
:disabled="savingOrder || managedTags.length === 0 || !isManagedOrderDirty"
|
||||||
@click="saveManagedOrder"
|
@click="saveManagedOrder"
|
||||||
>
|
>
|
||||||
{{ savingOrder ? '저장 중' : '정렬 저장' }}
|
{{ savingOrder ? '저장 중' : '정렬 저장' }}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const form = reactive({
|
|||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 로그인 제출 가능 여부
|
||||||
|
* @returns {boolean} 제출 가능 여부
|
||||||
|
*/
|
||||||
|
const canSubmitSignIn = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 입력값을 검증한다.
|
* 로그인 입력값을 검증한다.
|
||||||
* @returns {boolean} 검증 통과 여부
|
* @returns {boolean} 검증 통과 여부
|
||||||
@@ -99,9 +105,9 @@ const submitSignIn = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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"
|
type="submit"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting || !canSubmitSignIn"
|
||||||
>
|
>
|
||||||
로그인
|
로그인
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user