v1.3.2: 어나운스 바 슬라이드·설정 내비 아이콘
어나운스 바는 숨김 확인 후 슬라이드 인/아웃하고 7일간 보지 않기를 지원한다. 설정 좌측 내비에 타임존·메인 화면·어나운스·Import/Export·스팸 필터 아이콘을 추가한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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]"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 링크로 연결한다.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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일간 보지 않기를 선택할 수 있습니다. 공지를 수정·저장하면 다시 노출됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||
|
||||
Reference in New Issue
Block a user