Files
sori.studio/components/site/SiteHeader.vue
zenn b77f37a94e v1.3.1: 어나운스 바·가입 금지 닉네임·설정 UI 개선
공개 상단 어나운스 바와 관리자 맞춤 설정을 추가하고, 스팸 필터에서 가입 금지 닉네임을 관리·검증한다. POST 설정 읽기 모드 비활성 토글과 설정 내비 아이콘 틀을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 15:50:47 +09:00

309 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">
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
</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 || 'Anonymous' }}
</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>