v0.0.87: 저장·로그인 버튼 비활성 기본, 글 목록 삭제 아이콘, 푸시 지침

This commit is contained in:
2026-05-12 10:08:18 +09:00
parent 79fb354d91
commit 1d9a3e4527
11 changed files with 129 additions and 19 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? '저장 중' : '정렬 저장' }}