v0.0.87: 저장·로그인 버튼 비활성 기본, 글 목록 삭제 아이콘, 푸시 지침
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -21,6 +21,47 @@ 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 - 토스트 타입
|
||||
@@ -114,7 +155,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -129,7 +170,7 @@ const saveManagedOrder = async () => {
|
||||
})
|
||||
|
||||
tags.value = [...reordered]
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
showToast('success', '메인 태그 순서가 저장되었습니다.')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
|
||||
@@ -190,7 +231,7 @@ const promoteToMainTag = async (tag) => {
|
||||
tagType: 'managed'
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
await searchGeneralTags()
|
||||
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
||||
} catch (error) {
|
||||
@@ -224,7 +265,7 @@ const demoteToGeneralTag = async (tag) => {
|
||||
tagType: 'general'
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
await searchGeneralTags()
|
||||
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
||||
} catch (error) {
|
||||
@@ -250,7 +291,7 @@ const deleteGeneralTag = async (tag) => {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
await searchGeneralTags()
|
||||
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
||||
} catch (error) {
|
||||
@@ -291,7 +332,7 @@ onBeforeUnmount(() => {
|
||||
<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 ? '저장 중' : '정렬 저장' }}
|
||||
|
||||
Reference in New Issue
Block a user