v1.3.1: 어나운스 바·가입 금지 닉네임·설정 UI 개선
공개 상단 어나운스 바와 관리자 맞춤 설정을 추가하고, 스팸 필터에서 가입 금지 닉네임을 관리·검증한다. POST 설정 읽기 모드 비활성 토글과 설정 내비 아이콘 틀을 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
61
components/admin/AdminSettingsNavIcon.vue
Normal file
61
components/admin/AdminSettingsNavIcon.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 관리자 사이트 설정 좌측 내비 아이콘
|
||||
* @property {string} [iconId] - 아이콘 식별자. 미지정·미구현 시 자리 표시(placeholder)만 렌더
|
||||
*/
|
||||
const props = defineProps({
|
||||
iconId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="admin-settings-nav-icon inline-flex shrink-0 items-center justify-center text-current"
|
||||
:class="iconId ? `admin-settings-nav-icon--${iconId}` : 'admin-settings-nav-icon--placeholder'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
v-if="iconId === 'title-desc'"
|
||||
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="-0.75 -0.75 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M2.109375 6.32625h18.28125s1.40625 0 1.40625 1.40625v7.03125s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-7.03125s0 -1.40625 1.40625 -1.40625"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="m16.171875 17.57625 0 -12.65625"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M11.953125 21.795a4.21875 4.21875 0 0 0 4.21875 -4.21875 4.21875 4.21875 0 0 0 4.21875 4.21875"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M11.953125 0.70125a4.21875 4.21875 0 0 1 4.21875 4.21875 4.21875 4.21875 0 0 1 4.21875 -4.21875"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
v-else
|
||||
class="admin-settings-nav-icon__placeholder size-4 rounded-sm border border-dashed border-[#c8ced3]"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
116
components/site/SiteAnnouncementBar.vue
Normal file
116
components/site/SiteAnnouncementBar.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import {
|
||||
getAnnouncementBarTextColor,
|
||||
normalizeAnnouncementUrl
|
||||
} from '~/lib/announcement-bar.js'
|
||||
|
||||
const DISMISS_STORAGE_KEY = 'SITE_ANNOUNCEMENT_DISMISSED_AT'
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
announcementEnabled: false,
|
||||
announcementText: '',
|
||||
announcementUrl: '',
|
||||
announcementBackgroundColor: '#15171a',
|
||||
updatedAt: null
|
||||
})
|
||||
})
|
||||
|
||||
const dismissed = ref(false)
|
||||
|
||||
/**
|
||||
* 어나운스 바 노출 여부
|
||||
* @returns {boolean} 노출 여부
|
||||
*/
|
||||
const isVisible = computed(() => {
|
||||
if (!siteSettings.value?.announcementEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = (siteSettings.value?.announcementText || '').trim()
|
||||
if (!text) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !dismissed.value
|
||||
})
|
||||
|
||||
const announcementText = computed(() => (siteSettings.value?.announcementText || '').trim())
|
||||
|
||||
const announcementLink = computed(() => normalizeAnnouncementUrl(siteSettings.value?.announcementUrl || ''))
|
||||
|
||||
const barStyle = computed(() => {
|
||||
const backgroundColor = siteSettings.value?.announcementBackgroundColor || '#15171a'
|
||||
return {
|
||||
backgroundColor,
|
||||
color: getAnnouncementBarTextColor(backgroundColor)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 로컬에 저장된 닫기 상태를 반영한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncDismissedFromStorage = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(DISMISS_STORAGE_KEY)
|
||||
const updatedAt = siteSettings.value?.updatedAt || ''
|
||||
dismissed.value = Boolean(stored && stored === updatedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* 어나운스 바를 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const dismissAnnouncement = () => {
|
||||
dismissed.value = true
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(DISMISS_STORAGE_KEY, siteSettings.value?.updatedAt || '')
|
||||
}
|
||||
|
||||
watch(() => siteSettings.value?.updatedAt, syncDismissedFromStorage)
|
||||
|
||||
onMounted(() => {
|
||||
syncDismissedFromStorage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="site-announcement-bar relative z-30 min-h-9 w-full text-center text-sm font-medium"
|
||||
:style="barStyle"
|
||||
role="region"
|
||||
aria-label="사이트 공지"
|
||||
>
|
||||
<div class="site-announcement-bar__inner relative mx-auto flex max-w-[1294px] items-center justify-center px-10 py-2.5 lg:px-12">
|
||||
<component
|
||||
:is="announcementLink ? 'a' : 'span'"
|
||||
class="site-announcement-bar__text line-clamp-2"
|
||||
:class="announcementLink ? 'hover:underline' : ''"
|
||||
:href="announcementLink || undefined"
|
||||
:target="announcementLink ? '_blank' : undefined"
|
||||
:rel="announcementLink ? 'noreferrer' : undefined"
|
||||
>
|
||||
{{ announcementText }}
|
||||
</component>
|
||||
<button
|
||||
class="site-announcement-bar__close absolute top-1/2 right-3 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full opacity-80 transition hover:opacity-100 lg:right-4"
|
||||
type="button"
|
||||
aria-label="공지 닫기"
|
||||
@click="dismissAnnouncement"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,7 +151,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header sticky top-0 z-20 backdrop-blur">
|
||||
<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="/">
|
||||
|
||||
48
components/site/SiteTopChrome.vue
Normal file
48
components/site/SiteTopChrome.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
let resizeObserver = null
|
||||
|
||||
/**
|
||||
* 상단 크롬(어나운스 바·헤더) 높이를 CSS 변수로 반영한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncTopChromeHeight = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const chrome = document.querySelector('.site-top-chrome')
|
||||
const height = chrome instanceof HTMLElement ? chrome.offsetHeight : 57
|
||||
document.documentElement.style.setProperty('--site-top-chrome-height', `${height}px`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncTopChromeHeight()
|
||||
window.addEventListener('resize', syncTopChromeHeight)
|
||||
|
||||
const chrome = document.querySelector('.site-top-chrome')
|
||||
if (chrome instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncTopChromeHeight()
|
||||
})
|
||||
resizeObserver.observe(chrome)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', syncTopChromeHeight)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
document.documentElement.style.removeProperty('--site-top-chrome-height')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-top-chrome sticky top-0 z-20 shrink-0">
|
||||
<SiteAnnouncementBar />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user