273 lines
16 KiB
Vue
273 lines
16 KiB
Vue
<script setup>
|
|
const route = useRoute()
|
|
|
|
const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|
|
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
|
|
|
const editorDocumentClass = 'admin-post-editor-document'
|
|
const adminUserMenuOpen = ref(false)
|
|
|
|
const { data: adminMember } = await useFetch('/api/auth/me', {
|
|
default: () => ({
|
|
username: '',
|
|
email: '',
|
|
avatarUrl: ''
|
|
})
|
|
})
|
|
|
|
const { data: adminMembers } = await useFetch('/admin/api/members', {
|
|
default: () => []
|
|
})
|
|
|
|
const memberCount = computed(() => adminMembers.value.length)
|
|
const adminDisplayName = computed(() => adminMember.value?.username || adminMember.value?.email || '관리자')
|
|
const adminDisplayEmail = computed(() => adminMember.value?.email || '')
|
|
const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '')
|
|
const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A')
|
|
const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members')
|
|
|
|
/**
|
|
* 관리자 내비게이션 활성 경로 확인
|
|
* @param {string} path - 확인할 경로
|
|
* @returns {boolean} 활성 여부
|
|
*/
|
|
const isAdminNavActive = (path) => route.path === path || route.path.startsWith(`${path}/`)
|
|
|
|
/**
|
|
* 관리자 사용자 메뉴 닫기
|
|
* @returns {void}
|
|
*/
|
|
const closeAdminUserMenu = () => {
|
|
adminUserMenuOpen.value = false
|
|
}
|
|
|
|
/**
|
|
* 관리자 사용자 메뉴 토글
|
|
* @returns {void}
|
|
*/
|
|
const toggleAdminUserMenu = () => {
|
|
adminUserMenuOpen.value = !adminUserMenuOpen.value
|
|
}
|
|
|
|
/**
|
|
* 외부 클릭 시 관리자 사용자 메뉴 닫기
|
|
* @param {PointerEvent} event - 포인터 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onAdminDocumentPointerDown = (event) => {
|
|
if (!adminUserMenuOpen.value || !(event.target instanceof HTMLElement)) {
|
|
return
|
|
}
|
|
|
|
if (event.target.closest('[data-admin-user-menu]')) {
|
|
return
|
|
}
|
|
|
|
closeAdminUserMenu()
|
|
}
|
|
|
|
/**
|
|
* 글쓰기 전체 화면 문서 스크롤 잠금 적용
|
|
* @returns {void}
|
|
*/
|
|
const syncPostEditorDocumentClass = () => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
document.documentElement.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
|
document.body.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
|
}
|
|
|
|
watchEffect(syncPostEditorDocumentClass)
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
document.documentElement.classList.remove(editorDocumentClass)
|
|
document.body.classList.remove(editorDocumentClass)
|
|
document.removeEventListener('pointerdown', onAdminDocumentPointerDown)
|
|
})
|
|
|
|
/**
|
|
* 관리자 로그아웃
|
|
* @returns {Promise<void>} 로그아웃 처리 결과
|
|
*/
|
|
const logoutAdmin = async () => {
|
|
await $fetch('/admin/api/auth/logout', {
|
|
method: 'POST'
|
|
})
|
|
closeAdminUserMenu()
|
|
await navigateTo('/admin/login')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="admin-layout bg-[#f7f8fa] text-ink"
|
|
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
|
|
>
|
|
<aside
|
|
v-if="!isPostEditorRoute"
|
|
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-80 flex-col border-r border-[#e6e8eb] bg-[#f7f8fa] px-5 py-6 text-[#15171a] lg:flex"
|
|
>
|
|
<NuxtLink class="admin-layout__brand flex items-center gap-3 px-2 text-[0.95rem] font-semibold tracking-[-0.01em]" to="/admin">
|
|
<span class="admin-layout__brand-mark flex h-8 w-8 items-center justify-center rounded-full border border-[#d8dce1] bg-white shadow-[0_1px_2px_rgba(15,23,42,0.05)]">
|
|
<span class="h-5 w-5 rounded-full border-2 border-[#15171a]" />
|
|
</span>
|
|
<span>sori.studio</span>
|
|
</NuxtLink>
|
|
<nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]">
|
|
<div
|
|
class="admin-layout__nav-item group flex items-center rounded-md transition-colors"
|
|
:class="isAdminNavActive('/admin/posts') ? 'bg-[#e9ecef] text-[#15171a]' : 'hover:bg-[#eceff2] hover:text-[#15171a]'"
|
|
>
|
|
<NuxtLink class="admin-layout__nav-link flex min-w-0 flex-1 items-center gap-3 px-3 py-2" to="/admin/posts">
|
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M12.667 14.136l-3.712.531.53-3.713 9.546-9.546a2.25 2.25 0 013.182 3.182z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<path d="M19.122 14.25v7.5a1.5 1.5 0 01-1.5 1.5h-15a1.5 1.5 0 01-1.5-1.5v-15a1.5 1.5 0 011.5-1.5h7.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
</svg>
|
|
<span class="truncate">게시글</span>
|
|
</NuxtLink>
|
|
<NuxtLink
|
|
class="admin-layout__nav-create mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[#5d6673] transition-colors hover:bg-white hover:text-[#15171a] hover:shadow-[0_1px_2px_rgba(15,23,42,0.08)]"
|
|
to="/admin/posts/new"
|
|
aria-label="새 게시글 작성"
|
|
>
|
|
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
</svg>
|
|
</NuxtLink>
|
|
</div>
|
|
<NuxtLink
|
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
|
:class="isAdminNavActive('/admin/pages') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
|
to="/admin/pages"
|
|
>
|
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M16.5 21.513a1.5 1.5 0 01-1.9 1.446L4.1 20.042A1.5 1.5 0 013 18.6V2.487a1.5 1.5 0 011.9-1.446l10.5 3.391a1.5 1.5 0 011.1 1.445z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<path d="M4.5.987h15a1.5 1.5 0 011.5 1.5v15.75a1.5 1.5 0 01-1.5 1.5h-3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
</svg>
|
|
<span>페이지</span>
|
|
</NuxtLink>
|
|
<NuxtLink
|
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
|
:class="isAdminNavActive('/admin/tags') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
|
to="/admin/tags"
|
|
>
|
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M1.061 2.56v6.257a3 3 0 00.878 2.121L13.5 22.5a1.5 1.5 0 002.121 0l6.879-6.88a1.5 1.5 0 000-2.121L10.939 1.938a3 3 0 00-2.121-.878H2.561a1.5 1.5 0 00-1.5 1.5z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
</svg>
|
|
<span>태그</span>
|
|
</NuxtLink>
|
|
<NuxtLink
|
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
|
:class="isAdminNavActive('/admin/media') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
|
to="/admin/media"
|
|
>
|
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true">
|
|
<path d="M305-193.85v-191.54l153.46 95.77L305-193.85Zm215-373.84q-47.44 0-80.64-33.18-33.21-33.18-33.21-80.58t33.21-80.67q33.2-33.26 80.64-33.26h43.85v47.69H520q-27.56 0-46.86 19.32-19.29 19.32-19.29 46.92t19.29 46.83q19.3 19.24 46.86 19.24h43.85v47.69H520Zm116.15 0v-47.69H680q27.56 0 46.86-19.33 19.29-19.32 19.29-46.92t-19.29-46.83q-19.3-19.23-46.86-19.23h-43.85v-47.69H680q47.44 0 80.64 33.17 33.21 33.18 33.21 80.58t-33.21 80.67q-33.2 33.27-80.64 33.27h-43.85Zm-110-90v-47.69h147.7v47.69h-147.7Zm106.7 239.61v-60h194.84q5.39 0 8.85-3.46t3.46-8.85v-335.38q0-5.38-3.46-8.84-3.46-3.47-8.85-3.47H372.31q-5.39 0-8.85 3.47-3.46 3.46-3.46 8.84v336.15h-60v-336.15q0-29.82 21.24-51.07 21.24-21.24 51.07-21.24h455.38q29.83 0 51.07 21.24Q900-855.59 900-825.77v335.38q0 29.83-21.24 51.07-21.24 21.24-51.07 21.24H632.85ZM132.31-61.92q-29.83 0-51.07-21.24Q60-104.41 60-134.23V-445q0-29.83 21.24-51.07 21.24-21.24 51.07-21.24h455.38q29.83 0 51.07 21.24Q660-474.83 660-445v310.77q0 29.82-21.24 51.07-21.24 21.24-51.07 21.24H132.31Zm0-60h455.38q5.39 0 8.85-3.47 3.46-3.46 3.46-8.84V-445q0-5.39-3.46-8.85t-8.85-3.46H132.31q-5.39 0-8.85 3.46T120-445v310.77q0 5.38 3.46 8.84 3.46 3.47 8.85 3.47ZM600-658.08ZM360-289.62Z" />
|
|
</svg>
|
|
<span>미디어</span>
|
|
</NuxtLink>
|
|
<NuxtLink
|
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
|
:class="isAdminNavActive('/admin/navigation') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
|
to="/admin/navigation"
|
|
>
|
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="-0.75 -0.75 24 24" aria-hidden="true">
|
|
<path d="M2.109375 0.7059375h18.28125s1.40625 0 1.40625 1.40625v18.28125s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-18.28125s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<path d="m6.328125 7.0340625 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<path d="m6.328125 11.252812500000001 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<path d="m6.328125 15.471562500000001 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
</svg>
|
|
<span>네비게이션</span>
|
|
</NuxtLink>
|
|
<NuxtLink
|
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
|
:class="isAdminNavActive('/admin/members') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
|
to="/admin/members"
|
|
>
|
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle cx="7.5" cy="7.875" r="4.125" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<path d="M.75 20.25a6.75 6.75 0 0113.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<circle cx="17.727" cy="10.125" r="3.375" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<path d="M15.813 15.068a5.526 5.526 0 017.437 5.182" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
</svg>
|
|
<span class="min-w-0 flex-1">멤버</span>
|
|
<span class="admin-layout__member-count ml-auto rounded-full bg-[#e1e5e9] px-2 py-0.5 text-xs font-semibold text-[#5d6673]">
|
|
{{ memberCount }}
|
|
</span>
|
|
</NuxtLink>
|
|
</nav>
|
|
<div class="admin-layout__bottom relative mt-auto pt-6" data-admin-user-menu>
|
|
<div
|
|
v-if="adminUserMenuOpen"
|
|
class="admin-layout__user-popover absolute bottom-14 left-0 right-0 overflow-hidden rounded-xl border border-[#e2e5e9] bg-white text-[#15171a] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
|
>
|
|
<div class="admin-layout__user-summary flex items-center gap-3 border-b border-[#eceff2] px-4 py-4">
|
|
<img
|
|
v-if="adminAvatarUrl"
|
|
class="admin-layout__user-avatar h-11 w-11 rounded-full border border-[#e2e5e9] object-cover"
|
|
:src="adminAvatarUrl"
|
|
:alt="adminDisplayName"
|
|
>
|
|
<span v-else class="admin-layout__user-avatar flex h-11 w-11 items-center justify-center rounded-full border border-[#e2e5e9] bg-[#15171a] text-sm font-semibold text-white">
|
|
{{ adminAvatarInitial }}
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block truncate text-sm font-semibold">{{ adminDisplayName }}</span>
|
|
<span class="block truncate text-xs text-[#657080]">{{ adminDisplayEmail }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="admin-layout__user-actions grid py-2 text-sm text-[#3f4650]">
|
|
<NuxtLink class="admin-layout__user-action px-4 py-2.5 hover:bg-[#f3f5f7]" :to="adminProfilePath" @click="closeAdminUserMenu">
|
|
내 프로필
|
|
</NuxtLink>
|
|
<button class="admin-layout__user-action px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="logoutAdmin">
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="admin-layout__bottom-row flex items-center justify-between gap-3 px-2">
|
|
<button class="admin-layout__user-trigger flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors hover:bg-[#eceff2]" type="button" :aria-expanded="adminUserMenuOpen" @click="toggleAdminUserMenu">
|
|
<img
|
|
v-if="adminAvatarUrl"
|
|
class="admin-layout__user-trigger-avatar h-8 w-8 rounded-full border border-[#d8dce1] object-cover"
|
|
:src="adminAvatarUrl"
|
|
:alt="adminDisplayName"
|
|
>
|
|
<span v-else class="admin-layout__user-trigger-avatar flex h-8 w-8 items-center justify-center rounded-full border border-[#d8dce1] bg-[#15171a] text-xs font-semibold text-white">
|
|
{{ adminAvatarInitial }}
|
|
</span>
|
|
<svg class="h-3 w-3 shrink-0 text-[#5d6673]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
|
|
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
<NuxtLink class="admin-layout__bottom-settings flex h-9 w-9 items-center justify-center rounded-md text-[#3f4650] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]" to="/admin/settings" aria-label="설정">
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
</svg>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
<main
|
|
class="admin-layout__main bg-paper"
|
|
:class="[
|
|
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen px-8 py-8 xl:px-12 xl:py-10',
|
|
{ 'lg:ml-80': !isPostEditorRoute }
|
|
]"
|
|
>
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</template>
|