317 lines
14 KiB
Vue
317 lines
14 KiB
Vue
<script setup>
|
|
const { menuOpen, toggleMenu, closeMenu } = useMenuState()
|
|
const menuUserOpen = ref(false)
|
|
const userMenuRef = ref(null)
|
|
const userMenuToggleRef = ref(null)
|
|
const searchOpen = ref(false)
|
|
const member = ref(null)
|
|
|
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
|
default: () => ({
|
|
title: 'sori.studio'
|
|
})
|
|
})
|
|
|
|
/**
|
|
* 사용자 메뉴를 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closeUserMenu = () => {
|
|
menuUserOpen.value = false
|
|
}
|
|
|
|
/**
|
|
* 통합 검색 모달을 연다.
|
|
* @returns {void}
|
|
*/
|
|
const openSearchModal = () => {
|
|
searchOpen.value = true
|
|
}
|
|
|
|
/**
|
|
* 입력 필드에 포커스가 있으면 `/` 검색 단축키를 무시한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {boolean} 무시할 때 true
|
|
*/
|
|
const shouldIgnoreSearchHotkey = (event) => {
|
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
return true
|
|
}
|
|
const target = event.target
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false
|
|
}
|
|
const tag = target.tagName
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
return true
|
|
}
|
|
if (target.isContentEditable) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* 사용자 메뉴를 토글한다.
|
|
* @returns {void}
|
|
*/
|
|
const toggleUserMenu = () => {
|
|
menuUserOpen.value = !menuUserOpen.value
|
|
}
|
|
|
|
/**
|
|
* 회원 세션 정보를 조회한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const fetchMember = async () => {
|
|
try {
|
|
member.value = await $fetch('/api/auth/me')
|
|
} catch {
|
|
member.value = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 회원 로그아웃을 처리한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const logoutMember = async () => {
|
|
await $fetch('/api/auth/logout', {
|
|
method: 'POST'
|
|
})
|
|
member.value = null
|
|
closeUserMenu()
|
|
await navigateTo('/')
|
|
}
|
|
|
|
/**
|
|
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
|
|
* @param {MouseEvent} event - 클릭 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onDocumentClick = (event) => {
|
|
const target = /** @type {Node | null} */ (event.target instanceof Node ? event.target : null)
|
|
if (!target) {
|
|
closeUserMenu()
|
|
return
|
|
}
|
|
|
|
const isInsideMenu = userMenuRef.value instanceof HTMLElement && userMenuRef.value.contains(target)
|
|
const isToggleButton = userMenuToggleRef.value instanceof HTMLElement && userMenuToggleRef.value.contains(target)
|
|
|
|
if (!isInsideMenu && !isToggleButton) {
|
|
closeUserMenu()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape·`/` 키로 패널을 제어한다(검색 모달 → 사용자 메뉴 → 모바일 좌측 메뉴, `/`는 검색 열기).
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onGlobalKeydown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
if (searchOpen.value) {
|
|
searchOpen.value = false
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
if (menuUserOpen.value) {
|
|
closeUserMenu()
|
|
return
|
|
}
|
|
if (!menuOpen.value) {
|
|
return
|
|
}
|
|
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
|
|
closeMenu()
|
|
}
|
|
return
|
|
}
|
|
if (event.key !== '/') {
|
|
return
|
|
}
|
|
if (shouldIgnoreSearchHotkey(event)) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
openSearchModal()
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchMember()
|
|
document.addEventListener('click', onDocumentClick)
|
|
document.addEventListener('keydown', onGlobalKeydown)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', onDocumentClick)
|
|
document.removeEventListener('keydown', onGlobalKeydown)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<header class="site-header backdrop-blur">
|
|
<div class="site-header__inner mx-auto grid h-full max-w-[1294px] grid-cols-3 items-center gap-2 px-4 sm:gap-3 lg:gap-4 lg:px-5 xl:gap-5 xl:px-6 2xl:px-0">
|
|
<div class="site-header__brand-slot flex min-w-0 justify-self-start">
|
|
<NuxtLink class="site-header__brand flex min-w-0 max-w-full items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,28vw)] xl:max-w-[min(300px,26vw)]" to="/">
|
|
<button
|
|
class="site-header__menu-toggle group flex h-7 w-7 items-center justify-center rounded-full transition-transform"
|
|
type="button"
|
|
data-menu-toggle
|
|
aria-label="Menu toggle"
|
|
aria-haspopup="true"
|
|
aria-controls="menu"
|
|
:aria-expanded="menuOpen.toString()"
|
|
@click.prevent="toggleMenu"
|
|
>
|
|
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
|
|
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
|
<path d="M9 4v16" />
|
|
</svg>
|
|
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
|
<path d="M9 4v16" />
|
|
<path d="m14 10 2 2-2 2" />
|
|
</svg>
|
|
</span>
|
|
<span v-else class="site-header__menu-icon pointer-events-none">
|
|
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
|
|
</svg>
|
|
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
|
</NuxtLink>
|
|
</div>
|
|
<div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
|
|
<button
|
|
type="button"
|
|
class="site-header__search site-header__search--responsive hidden h-9 w-full min-w-[470px] max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
|
|
aria-label="검색 열기"
|
|
@click="openSearchModal"
|
|
>
|
|
<span class="site-header__search-icon mr-2 text-lg leading-none">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<circle cx="10" cy="10" r="7"></circle>
|
|
<line x1="21" y1="21" x2="15" y2="15"></line>
|
|
</svg>
|
|
</span>
|
|
<span class="site-header__search-text site-soft">Search</span>
|
|
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
|
</button>
|
|
</div>
|
|
<nav class="site-header__nav site-header__actions flex min-w-0 shrink-0 items-center justify-end justify-self-end gap-3 text-sm sm:gap-3.5">
|
|
<div class="site-header__user-menu relative">
|
|
<button
|
|
ref="userMenuToggleRef"
|
|
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full border transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
|
|
type="button"
|
|
aria-label="Toggle user menu"
|
|
:aria-expanded="menuUserOpen.toString()"
|
|
@click="toggleUserMenu"
|
|
>
|
|
<img
|
|
v-if="member?.avatarUrl"
|
|
:src="member.avatarUrl"
|
|
:alt="member.username || '회원 아바타'"
|
|
class="h-full w-full rounded-full object-cover"
|
|
>
|
|
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4 text-[var(--site-text)]"
|
|
viewBox="0 -960 960 960"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M367-527q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm296.5-343.5Q560-607 560-640t-23.5-56.5Q513-720 480-720t-56.5 23.5Q400-673 400-640t23.5 56.5Q447-560 480-560t56.5-23.5ZM480-640Zm0 400Z" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
|
|
<Transition
|
|
enter-active-class="transition-[transform,opacity,visibility] duration-200 ease-out"
|
|
enter-from-class="-translate-y-2 scale-95 opacity-0"
|
|
enter-to-class="translate-y-0 scale-100 opacity-100"
|
|
leave-active-class="transition-[transform,opacity,visibility] duration-150 ease-in"
|
|
leave-from-class="translate-y-0 scale-100 opacity-100"
|
|
leave-to-class="-translate-y-2 scale-95 opacity-0"
|
|
>
|
|
<div
|
|
v-if="menuUserOpen"
|
|
ref="userMenuRef"
|
|
class="site-header__user-dropdown absolute top-12 right-2 z-30 flex min-w-[200px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3 pb-2 text-sm font-medium shadow-[0_12px_30px_rgba(0,0,0,0.12)] sm:right-0 sm:max-w-xs"
|
|
>
|
|
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
|
|
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
|
|
<img
|
|
v-if="member?.avatarUrl"
|
|
:src="member.avatarUrl"
|
|
:alt="member.username || '회원 아바타'"
|
|
class="h-full w-full object-cover"
|
|
>
|
|
<span v-else class="text-base font-normal uppercase md:text-lg">
|
|
{{ (member?.username || member?.email || '@').slice(0, 1) }}
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-col gap-0.5">
|
|
<div class="max-w-xs truncate leading-[1.15]">
|
|
{{ member?.username || 'Guest' }}
|
|
</div>
|
|
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
|
|
{{ member.email }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="member">
|
|
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
|
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
|
|
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 1-2 0 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 1 0-2 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 1 2 0 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.24.36.48.69.6 1 .18.45.18 1.55 0 2-.12.31-.36.64-.6 1Z" />
|
|
</svg>
|
|
<span>설정</span>
|
|
</NuxtLink>
|
|
<button class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-left transition-colors duration-150 hover:bg-[var(--site-panel)]" type="button" @click="logoutMember">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
|
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
|
<path d="M21 12h-13l3 -3" />
|
|
<path d="M11 15l-3 -3" />
|
|
</svg>
|
|
<span>로그아웃</span>
|
|
</button>
|
|
</template>
|
|
<template v-else>
|
|
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
|
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
|
<path d="M15 9l-6 6" />
|
|
<path d="M15 15v-6h-6" />
|
|
</svg>
|
|
<span>Sign up</span>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
|
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
|
<path d="M21 12h-13l3 -3" />
|
|
<path d="M11 15l-3 -3" />
|
|
</svg>
|
|
<span>Sign in</span>
|
|
</NuxtLink>
|
|
</template>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
<SiteSearchModal v-model="searchOpen" />
|
|
</template>
|