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) + } }) 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일간 보지 않기를 선택할 수 있습니다. 공지를 수정·저장하면 다시 노출됩니다.