From b6a3228b09fb4fd7f128fb459ab93bcbac6ca714 Mon Sep 17 00:00:00 2001
From: zenn
Date: Tue, 19 May 2026 16:23:20 +0900
Subject: [PATCH] =?UTF-8?q?v1.3.2:=20=EC=96=B4=EB=82=98=EC=9A=B4=EC=8A=A4?=
=?UTF-8?q?=20=EB=B0=94=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=93=9C=C2=B7?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=82=B4=EB=B9=84=20=EC=95=84=EC=9D=B4?=
=?UTF-8?q?=EC=BD=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
어나운스 바는 숨김 확인 후 슬라이드 인/아웃하고 7일간 보지 않기를 지원한다. 설정 좌측 내비에 타임존·메인 화면·어나운스·Import/Export·스팸 필터 아이콘을 추가한다.
Co-authored-by: Cursor
---
components/admin/AdminSettingsNavIcon.vue | 107 +++++++++----
components/site/SiteAnnouncementBar.vue | 186 ++++++++++++++++------
docs/spec.md | 2 +-
docs/update.md | 6 +
lib/announcement-bar.js | 100 ++++++++++++
package.json | 2 +-
pages/admin/settings/index.vue | 12 +-
7 files changed, 325 insertions(+), 90 deletions(-)
diff --git a/components/admin/AdminSettingsNavIcon.vue b/components/admin/AdminSettingsNavIcon.vue
index d9076a9..7dd5e1f 100644
--- a/components/admin/AdminSettingsNavIcon.vue
+++ b/components/admin/AdminSettingsNavIcon.vue
@@ -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"
>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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}
*/
-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)
+ }
})
-
-
+
- {{ announcementText }}
-
-
+
+
+ {{ announcementText }}
+
+
+
+
+
+
+
diff --git a/docs/spec.md b/docs/spec.md
index 687d4ce..2be45af 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -567,7 +567,7 @@ components/content/
- **메인 화면**(`home_cover_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록·공개 글 상세에 수정 시각 보조 줄을 표시할지 여부.
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
-- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자가 닫으면 `localStorage` 키 `SITE_ANNOUNCEMENT_DISMISSED_AT`에 당시 `updatedAt`을 저장하고, 설정이 다시 저장되어 `updatedAt`이 바뀌면 배너가 다시 나타난다.
+- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
diff --git a/docs/update.md b/docs/update.md
index 1a9a8ae..89d1e6f 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,11 @@
# 업데이트 이력
+## v1.3.2
+
+- 관리자 설정 내비: 타임존·메인 화면·어나운스 바·Import/Export·스팸 필터 아이콘 추가.
+- 어나운스 바: 클라이언트에서 숨김 여부 확인 후 아래로 슬라이드 인. 닫기·7일간 보지 않기 시 위로 슬라이드 아웃 후 제거(깜빡임 방지).
+- 어나운스 바: X는 이번 방문(세션)만 숨김, `7일간 보지 않기` 텍스트 버튼으로 localStorage 7일 스누즈.
+
## v1.3.1
- 스팸 필터: 가입 금지 닉네임(`signupBlockedUsernames`) 설정·저장. 기본 admin, master, zenn, sori, sori.studio. 회원가입·프로필 닉네임 변경 시 검사, `zenn은 사용할 수 없는 단어입니다.` 형식 안내. 마이그레이션 `029_site_settings_signup_blocked_usernames.sql`.
diff --git a/lib/announcement-bar.js b/lib/announcement-bar.js
index de8d3b9..132c084 100644
--- a/lib/announcement-bar.js
+++ b/lib/announcement-bar.js
@@ -62,3 +62,103 @@ export const normalizeAnnouncementUrl = (url) => {
return ''
}
+
+/** @type {string} 어나운스 바 7일 숨김 localStorage 키 */
+export const ANNOUNCEMENT_DISMISS_STORAGE_KEY = 'SITE_ANNOUNCEMENT_DISMISS'
+
+/** @type {string} 어나운스 바 이번 방문(세션) 숨김 sessionStorage 키 */
+export const ANNOUNCEMENT_SESSION_DISMISS_KEY = 'SITE_ANNOUNCEMENT_SESSION_DISMISS'
+
+/** @type {number} 기본 숨김 일수 */
+export const ANNOUNCEMENT_SNOOZE_DAYS = 7
+
+/**
+ * 어나운스 바 닫기 식별 키(설정 저장 시각)
+ * @param {{ updatedAt?: string | null }} settings - 사이트 설정
+ * @returns {string} 식별 키
+ */
+export const getAnnouncementDismissKey = (settings) => settings?.updatedAt || ''
+
+/**
+ * 어나운스 바가 숨김 상태인지 확인한다.
+ * @param {{ updatedAt?: string | null }} settings - 사이트 설정
+ * @returns {boolean} 숨김 여부
+ */
+export const isAnnouncementDismissed = (settings) => {
+ if (!import.meta.client) {
+ return false
+ }
+
+ const key = getAnnouncementDismissKey(settings)
+ if (!key) {
+ return false
+ }
+
+ const sessionDismissed = sessionStorage.getItem(ANNOUNCEMENT_SESSION_DISMISS_KEY)
+ if (sessionDismissed === key) {
+ return true
+ }
+
+ const raw = localStorage.getItem(ANNOUNCEMENT_DISMISS_STORAGE_KEY)
+ if (!raw) {
+ return false
+ }
+
+ try {
+ const parsed = JSON.parse(raw)
+ if (parsed?.key !== key) {
+ return false
+ }
+
+ if (typeof parsed.until === 'number' && Date.now() < parsed.until) {
+ return true
+ }
+
+ if (parsed.until == null && parsed.key === key) {
+ return true
+ }
+ } catch {
+ return false
+ }
+
+ return false
+}
+
+/**
+ * 이번 브라우저 방문(세션) 동안만 어나운스 바를 숨긴다.
+ * @param {{ updatedAt?: string | null }} settings - 사이트 설정
+ * @returns {void}
+ */
+export const dismissAnnouncementForSession = (settings) => {
+ if (!import.meta.client) {
+ return
+ }
+
+ const key = getAnnouncementDismissKey(settings)
+ if (!key) {
+ return
+ }
+
+ sessionStorage.setItem(ANNOUNCEMENT_SESSION_DISMISS_KEY, key)
+}
+
+/**
+ * 지정 일수 동안 어나운스 바를 숨긴다.
+ * @param {{ updatedAt?: string | null }} settings - 사이트 설정
+ * @param {number} [days=ANNOUNCEMENT_SNOOZE_DAYS] - 숨김 일수
+ * @returns {void}
+ */
+export const dismissAnnouncementForDays = (settings, days = ANNOUNCEMENT_SNOOZE_DAYS) => {
+ if (!import.meta.client) {
+ return
+ }
+
+ const key = getAnnouncementDismissKey(settings)
+ if (!key) {
+ return
+ }
+
+ const until = Date.now() + days * 24 * 60 * 60 * 1000
+ localStorage.setItem(ANNOUNCEMENT_DISMISS_STORAGE_KEY, JSON.stringify({ key, until }))
+ sessionStorage.setItem(ANNOUNCEMENT_SESSION_DISMISS_KEY, key)
+}
diff --git a/package.json b/package.json
index eccd403..18cc7b0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"type": "module",
"imports": {
diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue
index ba32ec7..aff6924 100644
--- a/pages/admin/settings/index.vue
+++ b/pages/admin/settings/index.vue
@@ -172,7 +172,7 @@ const settingsNavGroups = [
heading: '일반',
items: [
{ id: 'admin-settings-section-title', label: '블로그 제목·설명', keywords: 'title description site name', iconId: 'title-desc' },
- { id: 'admin-settings-section-timezone', label: '타임존', keywords: 'timezone seoul gmt' },
+ { id: 'admin-settings-section-timezone', label: '타임존', keywords: 'timezone seoul gmt', iconId: 'timezone' },
{ id: 'admin-settings-section-misc', label: '기타 설정', keywords: 'logo url copyright favicon' }
]
},
@@ -185,15 +185,15 @@ const settingsNavGroups = [
{
heading: '사이트',
items: [
- { id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image' },
- { id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice' }
+ { id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image', iconId: 'home-cover' },
+ { id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice', iconId: 'announcement' }
]
},
{
heading: '콘텐츠·안전',
items: [
- { id: 'admin-settings-section-import-export', label: '게시물 Import/Export', keywords: 'import export backup' },
- { id: 'admin-settings-section-spam', label: '스팸 필터', keywords: 'spam moderation comments' }
+ { id: 'admin-settings-section-import-export', label: '게시물 Import/Export', keywords: 'import export backup', iconId: 'import-export' },
+ { id: 'admin-settings-section-spam', label: '스팸 필터', keywords: 'spam moderation comments', iconId: 'spam' }
]
}
]
@@ -1434,7 +1434,7 @@ onBeforeUnmount(() => {
v-if="!customizeAnnouncement"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
- 홈페이지 상단에 중요한 공지나 링크를 표시합니다. 방문자는 닫기 버튼으로 숨길 수 있으며, 설정을 바꾸면 다시 노출됩니다.
+ 홈페이지 상단에 중요한 공지나 링크를 표시합니다. 방문자는 X로 이번 방문만 닫거나, 7일간 보지 않기를 선택할 수 있습니다. 공지를 수정·저장하면 다시 노출됩니다.