관리자 사이드바 하단 사용자 메뉴 정리

This commit is contained in:
2026-05-13 10:38:10 +09:00
parent b490d5b90f
commit 6cb6268b43
7 changed files with 152 additions and 25 deletions

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-13 v0.0.109
### 관리자 사이드바 하단 사용자 영역 정리
상단 메뉴 아래에 로그아웃이 바로 붙어 있으면 Ghost형 관리자 내비게이션의 정보 구조와 달라지고, 주요 메뉴와 세션 액션이 같은 레벨로 보인다. 로그아웃은 하단 사용자 썸네일 드롭다운으로 옮기고, 설정은 하단 아이콘으로 배치해 상단 메뉴는 콘텐츠 관리 항목 중심으로 유지한다. 멤버 항목에는 총 멤버 수를 함께 보여 관리자가 현재 규모를 즉시 확인할 수 있게 한다.
## 2026-05-13 v0.0.108
### 관리자 캔버스 높이와 사이드바 폭 정리

View File

@@ -8,7 +8,7 @@
|------|------|
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
| layouts/page.vue | 고정 페이지 전체 화면 |
## Composables

View File

@@ -117,7 +117,10 @@ layouts/
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
- 관리자 우측 캔버스는 기본 `min-h-screen``bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
- 게시글·페이지·태그·미디어·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
- 로그아웃은 사이드바 상단 메뉴가 아니라 하단 사용자 썸네일 드롭다운 안에서 제공한다. 하단에는 사용자 썸네일 트리거와 설정 아이콘을 둔다.
---

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v0.0.109
- 관리자 사이드바 `메뉴` 항목을 `네비게이션`으로 변경하고 전용 아이콘 적용.
- 관리자 게시글 아이콘을 Ghost형 편집 아이콘으로 교체.
- 관리자 멤버 메뉴 우측에 총 멤버 수 표시 추가.
- 관리자 로그아웃을 상단 메뉴에서 제거하고 하단 사용자 드롭다운으로 이동.
- 관리자 하단에 사용자 썸네일 트리거와 설정 아이콘 추가.
- 패키지 버전 `0.0.109`로 갱신.
## v0.0.108
- 관리자 사이드바 너비를 Ghost 기준 320px로 조정.

View File

@@ -5,6 +5,25 @@ 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')
/**
* 관리자 내비게이션 활성 경로 확인
@@ -13,6 +32,39 @@ const editorDocumentClass = 'admin-post-editor-document'
*/
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}
@@ -28,6 +80,10 @@ const syncPostEditorDocumentClass = () => {
watchEffect(syncPostEditorDocumentClass)
onMounted(() => {
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
@@ -35,6 +91,7 @@ onBeforeUnmount(() => {
document.documentElement.classList.remove(editorDocumentClass)
document.body.classList.remove(editorDocumentClass)
document.removeEventListener('pointerdown', onAdminDocumentPointerDown)
})
/**
@@ -45,6 +102,7 @@ const logoutAdmin = async () => {
await $fetch('/admin/api/auth/logout', {
method: 'POST'
})
closeAdminUserMenu()
await navigateTo('/admin/login')
}
</script>
@@ -56,7 +114,7 @@ const logoutAdmin = async () => {
>
<aside
v-if="!isPostEditorRoute"
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-80 border-r border-[#e6e8eb] bg-[#f7f8fa] px-5 py-6 text-[#15171a] lg:block"
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)]">
@@ -71,7 +129,8 @@ const logoutAdmin = async () => {
>
<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="M5.5.75l10.72 10.72a.72.72 0 01.22.53.72.72 0 01-.22.53L5.5 23.25" stroke="#000" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
<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>
@@ -116,9 +175,18 @@ const logoutAdmin = async () => {
</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]" to="/admin/navigation">
<span class="admin-layout__nav-icon h-4 w-4 shrink-0 rounded-full border border-current" aria-hidden="true" />
<span>메뉴</span>
<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]"
@@ -131,23 +199,64 @@ const logoutAdmin = async () => {
<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>멤버</span>
<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>
<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/settings') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
to="/admin/settings"
>
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" 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>
<span>설정</span>
</NuxtLink>
<button class="admin-layout__logout mt-4 rounded-md px-3 py-2 text-left text-[#8a929d] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]" type="button" @click="logoutAdmin">
로그아웃
</button>
</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="/settings" @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"

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "0.0.108",
"version": "0.0.109",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "0.0.108",
"version": "0.0.109",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.108",
"version": "0.0.109",
"private": true,
"type": "module",
"imports": {