Files
sori.studio/layouts/admin.vue
zenn 10c5a099fc v1.4.3: 관리자 UI·홈·미디어 개선
- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬
- 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정
- 미디어 종류·미사용 필터, 비디오 프레임 썸네일
- NAS 운영 업데이트 절차 문서 추가
2026-05-21 18:30:50 +09:00

322 lines
18 KiB
Vue

<script setup>
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
/**
* 공개 블로그 베이스 URL (후행 슬래시 제거, 새 창 링크용)
* @returns {string} 절대 URL
*/
const publicBlogBaseUrl = computed(() => {
const raw = String(runtimeConfig.public?.siteUrl || '').trim()
return raw.replace(/\/+$/, '') || 'https://sori.studio'
})
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 settingsDocumentClass = 'admin-settings-document'
const adminUserMenuOpen = ref(false)
const isAdminSettingsRoute = computed(() => route.path === '/admin/settings'
|| route.path.startsWith('/admin/settings/'))
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')
const isAdminDashboardRoute = computed(() => route.path === '/admin')
/**
* 관리자 내비게이션 활성 경로 확인
* @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 syncAdminShellDocumentClass = () => {
if (!import.meta.client) {
return
}
const editorOn = isPostEditorRoute.value
const settingsOn = isAdminSettingsRoute.value && !editorOn
document.documentElement.classList.toggle(editorDocumentClass, editorOn)
document.body.classList.toggle(editorDocumentClass, editorOn)
document.documentElement.classList.toggle(settingsDocumentClass, settingsOn)
document.body.classList.toggle(settingsDocumentClass, settingsOn)
}
watchEffect(syncAdminShellDocumentClass)
onMounted(() => {
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
}
document.documentElement.classList.remove(editorDocumentClass)
document.body.classList.remove(editorDocumentClass)
document.documentElement.classList.remove(settingsDocumentClass)
document.body.classList.remove(settingsDocumentClass)
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 || isAdminSettingsRoute) ? 'h-screen overflow-hidden bg-white' : 'min-h-screen',
{ 'admin-layout--light-controls': !isPostEditorRoute }
]"
>
<aside
v-if="!isPostEditorRoute && !isAdminSettingsRoute"
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]">
<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="isAdminDashboardRoute ? 'bg-[#e9ecef] text-[#15171a]' : ''"
to="/admin"
>
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0 opacity-60" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.272 23.247a.981.981 0 00.978-.978V9.747a1.181 1.181 0 00-.377-.8L12 .747l-10.873 8.2a1.181 1.181 0 00-.377.8v12.522a.981.981 0 00.978.978z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<span>대시보드</span>
</NuxtLink>
<a
class="admin-layout__nav-link admin-layout__nav-link--external flex items-center gap-3 rounded-md px-3 py-2 text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
:href="publicBlogBaseUrl"
target="_blank"
rel="noopener noreferrer"
>
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<rect x="1.5" y="1.497" width="21" height="21" rx="1.5" ry="1.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M1.5 7.497h21m-13.5 15v-15" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<span>사이트 보기</span>
</a>
<div class="admin-layout__nav-divider h-6 shrink-0" aria-hidden="true" />
<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"
:class="[
isPostEditorRoute || isAdminSettingsRoute
? 'h-screen overflow-hidden bg-white'
: 'min-h-screen bg-paper px-8 py-8 xl:px-12 xl:py-10',
{ 'lg:ml-80': !isPostEditorRoute && !isAdminSettingsRoute }
]"
>
<slot />
</main>
</div>
</template>