v1.3.2: 어나운스 바 슬라이드·설정 내비 아이콘

어나운스 바는 숨김 확인 후 슬라이드 인/아웃하고 7일간 보지 않기를 지원한다. 설정 좌측 내비에 타임존·메인 화면·어나운스·Import/Export·스팸 필터 아이콘을 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-19 16:23:20 +09:00
parent b77f37a94e
commit b6a3228b09
7 changed files with 325 additions and 90 deletions

View File

@@ -3,7 +3,7 @@
* 관리자 사이트 설정 좌측 내비 아이콘
* @property {string} [iconId] - 아이콘 식별자. 미지정·미구현 시 자리 표시(placeholder)만 렌더
*/
const props = defineProps({
defineProps({
iconId: {
type: String,
default: ''
@@ -17,6 +17,7 @@ const props = defineProps({
: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"
@@ -24,35 +25,83 @@ const props = defineProps({
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"
/>
<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>
<!-- 타임존 -->
<svg
v-else-if="iconId === 'timezone'"
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="M10.546875 16.171875a5.625 5.625 0 1 0 11.25 0 5.625 5.625 0 1 0 -11.25 0Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m18.658125000000002 16.171875 -2.48625 0 0 -2.4853125" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M9.838125 21.703125a10.5478125 10.5478125 0 1 1 11.866875 -11.85375" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M8.7084375 21.4884375C7.2825 19.3959375 6.328125 15.593437499999999 6.328125 11.25S7.2825 3.105 8.7084375 1.0115625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m0.7265625 10.546875 8.9278125 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M2.8115625 4.921875 19.6875 4.921875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m1.92 16.171875 5.814375 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M13.7915625 1.0115625a15.9215625 15.9215625 0 0 1 2.15625 6.69" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- 메인 화면 -->
<svg
v-else-if="iconId === 'home-cover'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
>
<path d="M3 2.25h18s1.5 0 1.5 1.5v16.5s0 1.5 -1.5 1.5H3s-1.5 0 -1.5 -1.5V3.75s0 -1.5 1.5 -1.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m1.5 6.75 21 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m9 6.75 0 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m9 14.25 13.5 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- 어나운스 -->
<svg
v-else-if="iconId === 'announcement'"
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="M6.328125 14.296875H4.21875a3.515625 3.515625 0 0 1 0 -7.03125h2.109375Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M6.328125 14.296875a20.90625 20.90625 0 0 1 11.593125 3.5100000000000002l1.0631249999999999 0.70875V3.046875l-1.0631249999999999 0.70875A20.90625 20.90625 0 0 1 6.328125 7.265625Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m21.796875 9.375 0 2.8125" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M6.328125 14.296875A6.7865625 6.7865625 0 0 0 8.4375 19.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- Import/Export -->
<svg
v-else-if="iconId === 'import-export'"
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="m11.2509375 3.515625 0 11.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m7.0321875 10.546875 4.21875 4.21875 4.21875 -4.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M21.797812500000003 14.765625v1.40625a2.8125 2.8125 0 0 1 -2.8125 2.8125h-15.46875a2.8125 2.8125 0 0 1 -2.8125 -2.8125v-1.40625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- 스팸 필터 -->
<svg
v-else-if="iconId === 'spam'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
>
<path d="M19.0902 4.90918L4.9082 19.0912" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span
v-else
class="admin-settings-nav-icon__placeholder size-4 rounded-sm border border-dashed border-[#c8ced3]"

View File

@@ -1,10 +1,15 @@
<script setup>
import {
ANNOUNCEMENT_SNOOZE_DAYS,
dismissAnnouncementForDays,
dismissAnnouncementForSession,
getAnnouncementBarTextColor,
isAnnouncementDismissed,
normalizeAnnouncementUrl
} from '~/lib/announcement-bar.js'
const DISMISS_STORAGE_KEY = 'SITE_ANNOUNCEMENT_DISMISSED_AT'
/** @type {number} 슬라이드 애니메이션 시간(ms) */
const SLIDE_MS = 320
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
@@ -16,29 +21,33 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
})
})
/** DOM에 바를 둘지(애니메이션 종료 전까지 유지) */
const inDom = ref(false)
/** 펼침(아래로 슬라이드) 애니메이션 상태 */
const expanded = ref(false)
/** 숨김 처리 완료 여부 */
const dismissed = ref(false)
let closeTimer = null
/**
* 어나운스 바 노출 여부
* @returns {boolean} 노출 여부
* 설정상 어나운스 바를 켤 수 있는지
* @returns {boolean} 가능 여부
*/
const isVisible = computed(() => {
const isEligible = computed(() => {
if (!siteSettings.value?.announcementEnabled) {
return false
}
const text = (siteSettings.value?.announcementText || '').trim()
if (!text) {
return false
}
return !dismissed.value
return Boolean((siteSettings.value?.announcementText || '').trim())
})
const announcementText = computed(() => (siteSettings.value?.announcementText || '').trim())
const announcementLink = computed(() => normalizeAnnouncementUrl(siteSettings.value?.announcementUrl || ''))
const snoozeLabel = computed(() => `${ANNOUNCEMENT_SNOOZE_DAYS}일간 보지 않기`)
const barStyle = computed(() => {
const backgroundColor = siteSettings.value?.announcementBackgroundColor || '#15171a'
return {
@@ -48,69 +57,140 @@ const barStyle = computed(() => {
})
/**
* 로컬에 저장된 닫기 상태를 반영한다.
* @returns {void}
* 어나운스 바를 펼친다.
* @returns {Promise<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)
const openBar = async () => {
inDom.value = true
expanded.value = false
await nextTick()
requestAnimationFrame(() => {
requestAnimationFrame(() => {
expanded.value = true
})
})
}
/**
* 어나운스 바를 닫는다.
* 어나운스 바를 접고 DOM에서 제거한다.
* @param {() => void} persistDismiss - localStorage·sessionStorage 저장
* @returns {void}
*/
const dismissAnnouncement = () => {
dismissed.value = true
const closeBar = (persistDismiss) => {
if (!inDom.value) {
return
}
persistDismiss()
expanded.value = false
if (closeTimer) {
clearTimeout(closeTimer)
}
closeTimer = setTimeout(() => {
inDom.value = false
dismissed.value = true
closeTimer = null
}, SLIDE_MS)
}
/**
* 이번 방문(세션) 동안만 닫는다.
* @returns {void}
*/
const dismissForSession = () => {
closeBar(() => dismissAnnouncementForSession(siteSettings.value))
}
/**
* N일간 보지 않기로 닫는다.
* @returns {void}
*/
const dismissForSnooze = () => {
closeBar(() => dismissAnnouncementForDays(siteSettings.value))
}
watch(() => siteSettings.value?.updatedAt, async () => {
if (!import.meta.client) {
return
}
localStorage.setItem(DISMISS_STORAGE_KEY, siteSettings.value?.updatedAt || '')
}
const hidden = isAnnouncementDismissed(siteSettings.value)
dismissed.value = hidden
watch(() => siteSettings.value?.updatedAt, syncDismissedFromStorage)
if (!isEligible.value || hidden) {
expanded.value = false
inDom.value = false
return
}
await openBar()
})
onMounted(() => {
syncDismissedFromStorage()
dismissed.value = isAnnouncementDismissed(siteSettings.value)
if (isEligible.value && !dismissed.value) {
openBar()
}
})
onBeforeUnmount(() => {
if (closeTimer) {
clearTimeout(closeTimer)
}
})
</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="사이트 공지"
v-if="inDom"
class="site-announcement-bar-shell grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"
:class="expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
:style="{ transitionDuration: `${SLIDE_MS}ms` }"
>
<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"
<div class="min-h-0 overflow-hidden">
<div
class="site-announcement-bar relative z-30 w-full text-center text-sm font-medium"
:style="barStyle"
role="region"
aria-label="사이트 공지"
:aria-hidden="(!expanded).toString()"
>
{{ 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 class="site-announcement-bar__inner relative mx-auto flex min-h-9 max-w-[1294px] items-center justify-center gap-3 px-4 py-2.5 sm:px-6 lg:px-8">
<component
:is="announcementLink ? 'a' : 'span'"
class="site-announcement-bar__text min-w-0 flex-1 line-clamp-2 px-6 sm:px-8"
:class="announcementLink ? 'hover:underline' : ''"
:href="announcementLink || undefined"
:target="announcementLink ? '_blank' : undefined"
:rel="announcementLink ? 'noreferrer' : undefined"
>
{{ announcementText }}
</component>
<div class="site-announcement-bar__actions absolute top-1/2 right-3 flex shrink-0 -translate-y-1/2 items-center gap-2 sm:right-4 lg:right-5">
<button
class="site-announcement-bar__snooze whitespace-nowrap text-xs font-medium underline-offset-2 opacity-90 transition hover:underline hover:opacity-100 sm:text-[13px]"
type="button"
@click="dismissForSnooze"
>
{{ snoozeLabel }}
</button>
<button
class="site-announcement-bar__close inline-flex size-7 items-center justify-center rounded-full opacity-80 transition hover:opacity-100"
type="button"
aria-label="이번 방문 동안 닫기"
@click="dismissForSession"
>
<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>
</div>
</div>
</div>
</template>