v1.3.1: 어나운스 바·가입 금지 닉네임·설정 UI 개선

공개 상단 어나운스 바와 관리자 맞춤 설정을 추가하고, 스팸 필터에서 가입 금지 닉네임을 관리·검증한다. POST 설정 읽기 모드 비활성 토글과 설정 내비 아이콘 틀을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-19 15:50:47 +09:00
parent 02d33996c5
commit b77f37a94e
23 changed files with 934 additions and 47 deletions

View 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>

View 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>

View File

@@ -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="/">

View 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>

View File

@@ -0,0 +1,5 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS announcement_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS announcement_text TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS announcement_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS announcement_background_color TEXT NOT NULL DEFAULT '#15171a';

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS signup_blocked_usernames TEXT NOT NULL DEFAULT '["admin","master","zenn","sori","sori.studio"]';

View File

@@ -143,6 +143,8 @@ docker compose --env-file .env.production exec sori-studio-db psql -U sori_studi
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/025_posts_status_no_private.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/026_site_settings_show_post_updated_at.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/027_site_settings_home_cover.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/028_site_settings_announcement.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/029_site_settings_signup_blocked_usernames.sql
```
### Docker 네트워크 충돌 대응

View File

@@ -51,6 +51,8 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteTopChrome.vue | 공개 레이아웃 상단 고정 영역(어나운스 바+헤더), `--site-top-chrome-height` CSS 변수 |
| components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·배경색·닫기) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
@@ -67,6 +69,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달, 커서 블록 컨텍스트·`block-panel` emit |
@@ -127,7 +130,8 @@
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 수정일 표시 토글·저장), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(커버 이미지·오버레이 텍스트), 타임존·어나운스·Import/Export·스팸 플레이스홀더 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 토글), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색), **스팸 필터**(가입 금지 닉네임), 타임존·Import/Export 플레이스홀더 |
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |

View File

@@ -378,7 +378,7 @@ components/content/
- `GET /api/pages/:slug` - 고정 페이지 상세
- `GET /api/tags` - 태그 목록
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status``emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
@@ -434,10 +434,10 @@ components/content/
- `PUT /admin/api/tags/:id` - 태그 수정
- `DELETE /admin/api/tags/:id` - 태그 삭제
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt` 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `homeCoverImageUrl`, `homeCoverTitle`, `homeCoverText` 포함)
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 어나운스 바 필드 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, 홈 커버, 어나운스 바, `signupBlockedUsernames` 포함). `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 홈 커버 필드 포함)
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 홈 커버·어나운스 바 필드 포함)
- `GET /admin/api/navigation` - 네비게이션 항목 목록
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
@@ -561,11 +561,13 @@ components/content/
### 사이트 설정
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·어나운스 바·게시물 Import/Export·스팸 필터는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다.
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·게시물 Import/Export는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 준다.
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **메인 화면**(`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`이 바뀌면 배너가 다시 나타난다.
- 로고 이미지는 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 링크로 연결한다.

View File

@@ -1,5 +1,16 @@
# 업데이트 이력
## v1.3.1
- 스팸 필터: 가입 금지 닉네임(`signupBlockedUsernames`) 설정·저장. 기본 admin, master, zenn, sori, sori.studio. 회원가입·프로필 닉네임 변경 시 검사, `zenn은 사용할 수 없는 단어입니다.` 형식 안내. 마이그레이션 `029_site_settings_signup_blocked_usernames.sql`.
## v1.3.0
- 관리자 사이트 설정 좌측 내비: `AdminSettingsNavIcon`·`iconId` 슬롯 추가. 블로그 제목·설명에 type-cursor 아이콘 연결, 나머지는 placeholder 틀.
- 관리자 POST 설정: 읽기 모드에서도 수정일 표시를 비활성화 토글 UI로 표시(켜짐/꺼짐 텍스트 제거).
- 어나운스 바: `site_settings` 필드·마이그레이션 `028_site_settings_announcement.sql`. 공개 상단 배너(`SiteAnnouncementBar`·`SiteTopChrome`), 닫기 시 `localStorage`·설정 변경 시 재노출.
- 관리자 설정「어나운스 바」: 사용 토글·맞춤 설정(문구·선택 링크·배경색 프리셋 검정/흰/브랜드).
## v1.2.9
- 홈 상단: Ghost형 헤딩·구독 폼 제거. 사이트 설정「메인 화면」에서 커버 이미지(720px)·오버레이 제목·본문 설정. `HomeHero.vue`, 마이그레이션 `027_site_settings_home_cover.sql`.

View File

@@ -30,7 +30,9 @@ onBeforeUnmount(() => {
<template>
<div class="site-shell public-layout">
<SiteHeader class="shrink-0" />
<SiteTopChrome>
<SiteHeader />
</SiteTopChrome>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
leave-active-class="transition-opacity duration-200 ease-in"
@@ -39,7 +41,8 @@ onBeforeUnmount(() => {
>
<div
v-show="menuOpen"
class="public-layout__nav-backdrop fixed inset-x-0 top-[57px] bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
class="public-layout__nav-backdrop fixed inset-x-0 bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
style="top: var(--site-top-chrome-height, 57px)"
aria-hidden="true"
@click="closeMenu"
/>

View File

@@ -30,7 +30,9 @@ onBeforeUnmount(() => {
<template>
<div class="site-shell post-layout">
<SiteHeader class="shrink-0" />
<SiteTopChrome>
<SiteHeader />
</SiteTopChrome>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
leave-active-class="transition-opacity duration-200 ease-in"
@@ -39,7 +41,8 @@ onBeforeUnmount(() => {
>
<div
v-show="menuOpen"
class="post-layout__nav-backdrop fixed inset-x-0 top-[57px] bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
class="post-layout__nav-backdrop fixed inset-x-0 bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
style="top: var(--site-top-chrome-height, 57px)"
aria-hidden="true"
@click="closeMenu"
/>

64
lib/announcement-bar.js Normal file
View File

@@ -0,0 +1,64 @@
/**
* 어나운스 바 배경색 프리셋
* @type {ReadonlyArray<{ id: string, label: string, value: string, textColor: string }>}
*/
export const ANNOUNCEMENT_BACKGROUND_PRESETS = [
{ id: 'black', label: '검정', value: '#15171a', textColor: '#ffffff' },
{ id: 'white', label: '흰색', value: '#ffffff', textColor: '#15171a' },
{ id: 'accent', label: '브랜드', value: '#ff4f2e', textColor: '#ffffff' }
]
/** @type {string} 기본 어나운스 바 배경색 */
export const DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR = '#15171a'
/**
* 어나운스 바 배경색이 허용 프리셋인지 확인한다.
* @param {string} value - hex 색상
* @returns {boolean} 허용 여부
*/
export const isValidAnnouncementBackgroundColor = (value) => {
const normalized = (value || '').trim().toLowerCase()
return ANNOUNCEMENT_BACKGROUND_PRESETS.some((preset) => preset.value.toLowerCase() === normalized)
}
/**
* 어나운스 바 배경색에 맞는 전경색을 반환한다.
* @param {string} backgroundColor - hex 배경색
* @returns {string} 전경 hex 색상
*/
export const getAnnouncementBarTextColor = (backgroundColor) => {
const normalized = (backgroundColor || '').trim().toLowerCase()
const preset = ANNOUNCEMENT_BACKGROUND_PRESETS.find((item) => item.value.toLowerCase() === normalized)
if (preset) {
return preset.textColor
}
return '#ffffff'
}
/**
* 어나운스 링크를 정리한다. 빈 값은 링크 미사용.
* @param {string} url - 입력 URL
* @returns {string} 정리된 URL 또는 빈 문자열
*/
export const normalizeAnnouncementUrl = (url) => {
const trimmed = (url || '').trim()
if (!trimmed) {
return ''
}
if (trimmed.startsWith('/')) {
return trimmed
}
try {
const parsed = new URL(trimmed)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString()
}
} catch {
return ''
}
return ''
}

View File

@@ -0,0 +1,110 @@
/** @type {readonly string[]} 가입 금지 닉네임 기본값 */
export const DEFAULT_SIGNUP_BLOCKED_USERNAMES = Object.freeze([
'admin',
'master',
'zenn',
'sori',
'sori.studio'
])
/** @type {number} 금지 닉네임 최대 개수 */
export const MAX_SIGNUP_BLOCKED_USERNAME_COUNT = 100
/** @type {number} 금지 닉네임 한 항목 최대 길이 */
export const MAX_SIGNUP_BLOCKED_USERNAME_LENGTH = 60
/**
* 금지 닉네임 목록을 정리한다.
* @param {unknown} value - 원본 값
* @returns {string[]} 정리된 목록
*/
export const normalizeSignupBlockedUsernames = (value) => {
const source = Array.isArray(value) ? value : []
const seen = new Set()
const result = []
for (const item of source) {
const term = String(item || '').trim()
if (!term) {
continue
}
const key = term.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
result.push(term.slice(0, MAX_SIGNUP_BLOCKED_USERNAME_LENGTH))
if (result.length >= MAX_SIGNUP_BLOCKED_USERNAME_COUNT) {
break
}
}
return result.length > 0 ? result : [...DEFAULT_SIGNUP_BLOCKED_USERNAMES]
}
/**
* DB에 저장된 금지 닉네임 JSON 문자열을 파싱한다.
* @param {unknown} raw - DB 값
* @returns {string[]} 정리된 목록
*/
export const parseSignupBlockedUsernamesFromDb = (raw) => {
if (Array.isArray(raw)) {
return normalizeSignupBlockedUsernames(raw)
}
if (typeof raw === 'string' && raw.trim()) {
try {
return normalizeSignupBlockedUsernames(JSON.parse(raw))
} catch {
return [...DEFAULT_SIGNUP_BLOCKED_USERNAMES]
}
}
return [...DEFAULT_SIGNUP_BLOCKED_USERNAMES]
}
/**
* 줄 단위 텍스트를 금지 닉네임 배열로 변환한다.
* @param {string} text - 줄바꿈 구분 입력
* @returns {string[]} 정리된 목록
*/
export const parseSignupBlockedUsernamesFromText = (text) => {
const lines = String(text || '').split(/\r?\n/)
return normalizeSignupBlockedUsernames(lines)
}
/**
* 닉네임에 금지 단어가 포함되는지 확인한다.
* @param {string} username - 닉네임
* @param {string[]} blockedList - 금지 목록
* @returns {string | null} 매칭된 금지 단어(표시용)
*/
export const getSignupBlockedUsernameMatch = (username, blockedList) => {
const normalized = username.trim().toLowerCase()
if (!normalized) {
return null
}
const terms = normalizeSignupBlockedUsernames(blockedList)
.slice()
.sort((left, right) => right.length - left.length)
for (const term of terms) {
const key = term.toLowerCase()
if (normalized === key || normalized.includes(key)) {
return term
}
}
return null
}
/**
* 금지 닉네임 안내 문구를 만든다.
* @param {string} matchedTerm - 매칭된 금지 단어
* @returns {string} 안내 문구
*/
export const formatSignupBlockedUsernameMessage = (matchedTerm) => `${matchedTerm}은 사용할 수 없는 단어입니다.`

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.2.9",
"version": "1.3.1",
"private": true,
"type": "module",
"imports": {

View File

@@ -1,4 +1,10 @@
<script setup>
import { ANNOUNCEMENT_BACKGROUND_PRESETS } from '~/lib/announcement-bar.js'
import {
normalizeSignupBlockedUsernames,
parseSignupBlockedUsernamesFromText
} from '~/lib/signup-blocked-usernames.js'
definePageMeta({
layout: 'admin'
})
@@ -9,6 +15,8 @@ const savingTitleDesc = ref(false)
const savingMisc = ref(false)
const savingPost = ref(false)
const savingHomeCover = ref(false)
const savingAnnouncement = ref(false)
const savingSpam = ref(false)
const uploadingLogo = ref(false)
const uploadingHomeCover = ref(false)
const errorMessage = ref('')
@@ -27,6 +35,10 @@ const editMisc = ref(false)
const editPost = ref(false)
/** 메인 화면 커버 카드 편집 모드 여부 */
const editHomeCover = ref(false)
/** 어나운스 바 맞춤 설정 패널 열림 여부 */
const customizeAnnouncement = ref(false)
/** 스팸 필터 카드 편집 모드 여부 */
const editSpam = ref(false)
/** 편집 시작 시점의 제목·설명(취소 시 복원용) */
const titleDescSnapshot = reactive({
title: '',
@@ -50,6 +62,17 @@ const homeCoverSnapshot = reactive({
homeCoverTitle: '',
homeCoverText: ''
})
/** 맞춤 설정 시작 시점의 어나운스 바(취소 시 복원용) */
const announcementSnapshot = reactive({
announcementEnabled: false,
announcementText: '',
announcementUrl: '',
announcementBackgroundColor: '#15171a'
})
/** 편집 시작 시점의 스팸 필터(취소 시 복원용) */
const spamSnapshot = reactive({
signupBlockedUsernames: []
})
let toastTimer = null
let scrollSpyFrame = null
@@ -66,7 +89,12 @@ const form = reactive({
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
homeCoverTitle: settings.value?.homeCoverTitle || '',
homeCoverText: settings.value?.homeCoverText || ''
homeCoverText: settings.value?.homeCoverText || '',
announcementEnabled: Boolean(settings.value?.announcementEnabled),
announcementText: settings.value?.announcementText || '',
announcementUrl: settings.value?.announcementUrl || '',
announcementBackgroundColor: settings.value?.announcementBackgroundColor || '#15171a',
signupBlockedUsernames: normalizeSignupBlockedUsernames(settings.value?.signupBlockedUsernames)
})
/**
@@ -108,20 +136,42 @@ const hasHomeCoverChanges = computed(() => editHomeCover.value && (
))
/**
* 수정일 표시 라벨
* @returns {string} 표시 문구
* 어나운스 바 변경 여부
* @returns {boolean} 변경 여부
*/
const showPostUpdatedAtLabel = computed(() => (form.showPostUpdatedAt ? '켜짐' : '꺼짐'))
const hasAnnouncementChanges = computed(() => customizeAnnouncement.value && (
form.announcementEnabled !== announcementSnapshot.announcementEnabled
|| form.announcementText !== announcementSnapshot.announcementText
|| form.announcementUrl !== announcementSnapshot.announcementUrl
|| form.announcementBackgroundColor !== announcementSnapshot.announcementBackgroundColor
))
/**
* 스팸 필터 변경 여부
* @returns {boolean} 변경 여부
*/
const hasSpamChanges = computed(() => editSpam.value
&& JSON.stringify(form.signupBlockedUsernames) !== JSON.stringify(spamSnapshot.signupBlockedUsernames))
/**
* 가입 금지 닉네임 textarea 바인딩
*/
const signupBlockedUsernamesText = computed({
get: () => form.signupBlockedUsernames.join('\n'),
set: (value) => {
form.signupBlockedUsernames = parseSignupBlockedUsernamesFromText(value)
}
})
/**
* 설정 화면 좌측 내비 구역 정의
* @type {ReadonlyArray<{ heading: string, items: ReadonlyArray<{ id: string, label: string, keywords: string }> }>}
* @type {ReadonlyArray<{ heading: string, items: ReadonlyArray<{ id: string, label: string, keywords: string, iconId?: string }> }>}
*/
const settingsNavGroups = [
{
heading: '일반',
items: [
{ id: 'admin-settings-section-title', label: '블로그 제목·설명', keywords: 'title description site name' },
{ 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-misc', label: '기타 설정', keywords: 'logo url copyright favicon' }
]
@@ -336,7 +386,12 @@ const buildSiteSettingsPayload = () => ({
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
homeCoverText: form.homeCoverText || ''
homeCoverText: form.homeCoverText || '',
announcementEnabled: Boolean(form.announcementEnabled),
announcementText: form.announcementText || '',
announcementUrl: form.announcementUrl || '',
announcementBackgroundColor: form.announcementBackgroundColor || '#15171a',
signupBlockedUsernames: normalizeSignupBlockedUsernames(form.signupBlockedUsernames)
})
/**
@@ -595,6 +650,91 @@ const saveHomeCoverSection = async () => {
}
}
/**
* 어나운스 바 맞춤 설정 패널을 연다.
* @returns {void}
*/
const beginCustomizeAnnouncement = () => {
announcementSnapshot.announcementEnabled = form.announcementEnabled
announcementSnapshot.announcementText = form.announcementText
announcementSnapshot.announcementUrl = form.announcementUrl
announcementSnapshot.announcementBackgroundColor = form.announcementBackgroundColor
customizeAnnouncement.value = true
}
/**
* 어나운스 바 맞춤 설정을 취소한다.
* @returns {void}
*/
const cancelCustomizeAnnouncement = () => {
form.announcementEnabled = announcementSnapshot.announcementEnabled
form.announcementText = announcementSnapshot.announcementText
form.announcementUrl = announcementSnapshot.announcementUrl
form.announcementBackgroundColor = announcementSnapshot.announcementBackgroundColor
customizeAnnouncement.value = false
}
/**
* 어나운스 바 설정을 저장한다.
* @returns {Promise<void>}
*/
const saveAnnouncementSection = async () => {
if (!hasAnnouncementChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '어나운스 바 설정이 저장되었습니다.',
savingFlag: savingAnnouncement
})
if (ok) {
announcementSnapshot.announcementEnabled = form.announcementEnabled
announcementSnapshot.announcementText = form.announcementText
announcementSnapshot.announcementUrl = form.announcementUrl
announcementSnapshot.announcementBackgroundColor = form.announcementBackgroundColor
customizeAnnouncement.value = false
}
}
/**
* 스팸 필터 편집 모드 진입
* @returns {void}
*/
const beginEditSpam = () => {
spamSnapshot.signupBlockedUsernames = [...form.signupBlockedUsernames]
editSpam.value = true
}
/**
* 스팸 필터 편집 취소
* @returns {void}
*/
const cancelEditSpam = () => {
form.signupBlockedUsernames = [...spamSnapshot.signupBlockedUsernames]
editSpam.value = false
}
/**
* 스팸 필터 저장
* @returns {Promise<void>}
*/
const saveSpamSection = async () => {
if (!hasSpamChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '스팸 필터 설정이 저장되었습니다.',
savingFlag: savingSpam
})
if (ok) {
spamSnapshot.signupBlockedUsernames = [...form.signupBlockedUsernames]
editSpam.value = false
}
}
/**
* Escape 키: 제목·설명 편집 중이면 취소, 아니면 설정 화면 닫기
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -619,6 +759,21 @@ const onGlobalKeydown = (event) => {
cancelEditPost()
return
}
if (editHomeCover.value) {
event.preventDefault()
cancelEditHomeCover()
return
}
if (customizeAnnouncement.value) {
event.preventDefault()
cancelCustomizeAnnouncement()
return
}
if (editSpam.value) {
event.preventDefault()
cancelEditSpam()
return
}
closeSettings()
}
@@ -703,6 +858,7 @@ onBeforeUnmount(() => {
type="button"
@click="scrollToSection(item.id)"
>
<AdminSettingsNavIcon :icon-id="item.iconId" />
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
</button>
</li>
@@ -925,7 +1081,7 @@ onBeforeUnmount(() => {
v-if="!editMisc"
class="admin-settings-screen__misc-readonly grid gap-6"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-4 mt-4">
<div class="grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-2xl border border-[#e6e8eb] bg-[#f7f8fa]">
<img
v-if="form.logoUrl"
@@ -1082,11 +1238,21 @@ onBeforeUnmount(() => {
<div
v-if="!editPost"
class="admin-settings-screen__post-readonly grid gap-2 text-sm"
class="admin-settings-screen__post-readonly border-t border-[#eceff2] pt-5 text-sm"
>
<div class="flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5">
<div class="admin-settings-screen__post-toggle flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">수정일 표시</span>
<span class="text-[#657080]">{{ showPostUpdatedAtLabel }}</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 cursor-not-allowed items-center opacity-80">
<input
:checked="form.showPostUpdatedAt"
class="peer sr-only"
type="checkbox"
disabled
aria-label="수정일 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</div>
</div>
@@ -1257,18 +1423,126 @@ onBeforeUnmount(() => {
<section
id="admin-settings-section-announcement"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
class="admin-settings-screen__card admin-settings-screen__card--announcement relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
>
<div class="admin-settings-screen__card-head mb-2">
<h2 class="text-lg font-semibold text-[#15171a]">
어나운스
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
사이트 상단 공지 배너 문구와 링크를 설정합니다. (준비 )
</p>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
어나운스
</h2>
<p
v-if="!customizeAnnouncement"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
홈페이지 상단에 중요한 공지나 링크를 표시합니다. 방문자는 닫기 버튼으로 숨길 있으며, 설정을 바꾸면 다시 노출됩니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!customizeAnnouncement">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="beginCustomizeAnnouncement"
>
맞춤 설정
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="savingAnnouncement"
@click="cancelCustomizeAnnouncement"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="savingAnnouncement || !hasAnnouncementChanges"
@click="saveAnnouncementSection"
>
{{ savingAnnouncement ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
이후 버전에서 노출 조건·스타일 옵션과 함께 제공합니다.
<label
v-if="!customizeAnnouncement"
class="admin-settings-screen__announcement-toggle flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<span class="font-bold text-[#15171a]">사용</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 cursor-not-allowed items-center opacity-80">
<input
:checked="form.announcementEnabled"
class="peer sr-only"
type="checkbox"
disabled
aria-label="어나운스 사용"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<div
v-else
class="admin-settings-screen__announcement-edit grid gap-6 border-t border-[#eceff2] pt-5"
>
<label class="admin-settings-screen__announcement-toggle flex items-center justify-between gap-4 text-sm">
<span class="font-bold text-[#15171a]">사용</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.announcementEnabled"
class="peer sr-only"
type="checkbox"
aria-label="어나운스 사용"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">어나운스</span>
<input
v-model="form.announcementText"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
maxlength="200"
placeholder="공지 문구"
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">링크 (선택)</span>
<input
v-model="form.announcementUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
maxlength="500"
placeholder="https://… 또는 /경로 (비우면 링크 없음)"
>
</label>
<div class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">배경색</span>
<div class="admin-settings-screen__announcement-colors flex flex-wrap items-center gap-3">
<button
v-for="preset in ANNOUNCEMENT_BACKGROUND_PRESETS"
:key="preset.id"
class="admin-settings-screen__announcement-color relative inline-flex size-9 items-center justify-center rounded-full border transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#15171a]"
:class="form.announcementBackgroundColor === preset.value ? 'border-[#22a06b] ring-2 ring-[#22a06b] ring-offset-2' : 'border-[#dce0e5]'"
type="button"
:style="{ backgroundColor: preset.value }"
:title="preset.label"
:aria-label="`${preset.label} 배경`"
:aria-pressed="form.announcementBackgroundColor === preset.value"
@click="form.announcementBackgroundColor = preset.value"
>
<span class="sr-only">{{ preset.label }}</span>
</button>
</div>
</div>
</div>
</section>
@@ -1294,18 +1568,83 @@ onBeforeUnmount(() => {
<section
id="admin-settings-section-spam"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
class="admin-settings-screen__card admin-settings-screen__card--spam relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
>
<div class="admin-settings-screen__card-head mb-2">
<h2 class="text-lg font-semibold text-[#15171a]">
스팸 필터
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
댓글·가입 등에서 스팸을 줄이기 위한 규칙을 설정합니다. (준비 )
</p>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
스팸 필터
</h2>
<p
v-if="!editSpam"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
회원가입 사용할 없는 닉네임을 지정합니다. 닉네임에 해당 단어가 포함되면 가입이 거부됩니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!editSpam">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="beginEditSpam"
>
편집
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="savingSpam"
@click="cancelEditSpam"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="savingSpam || !hasSpamChanges"
@click="saveSpamSection"
>
{{ savingSpam ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
이후 버전에서 키워드·링크 제한 옵션을 제공합니다.
<div
v-if="!editSpam"
class="admin-settings-screen__spam-readonly border-t border-[#eceff2] pt-5 text-sm"
>
<p class="font-medium text-[#3f4650]">
가입 금지 닉네임
</p>
<ul class="mt-3 grid gap-1.5 text-[#657080]">
<li
v-for="term in form.signupBlockedUsernames"
:key="term"
class="font-mono text-[13px] text-[#15171a]"
>
{{ term }}
</li>
</ul>
</div>
<div
v-else
class="admin-settings-screen__spam-edit grid gap-2 border-t border-[#eceff2] pt-5 text-sm"
>
<label class="admin-settings-screen__field grid gap-2">
<span class="font-medium text-[#3f4650]">가입 금지 닉네임</span>
<p class="text-xs leading-relaxed text-[#657080]">
줄에 하나씩 입력합니다. 대소문자는 구분하지 않으며, 닉네임에 해당 단어가 포함되면 사용할 없습니다.
</p>
<textarea
v-model="signupBlockedUsernamesText"
class="min-h-[10rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
rows="8"
placeholder="admin&#10;master&#10;zenn"
/>
</label>
</div>
</section>
</form>

View File

@@ -1,4 +1,9 @@
<script setup>
import {
formatSignupBlockedUsernameMessage,
getSignupBlockedUsernameMatch
} from '~/lib/signup-blocked-usernames.js'
definePageMeta({
layout: 'page'
})
@@ -12,7 +17,8 @@ const submitErrorMessage = ref('')
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'AFFiNE',
description: 'Configure your Self Host AFFiNE with a few simple settings.'
description: 'Configure your Self Host AFFiNE with a few simple settings.',
signupBlockedUsernames: []
})
})
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
@@ -78,6 +84,15 @@ const validateStepTwo = () => {
if (!form.username.trim()) {
errors.username = '사용자명을 입력해 주세요.'
valid = false
} else {
const blockedMatch = getSignupBlockedUsernameMatch(
form.username,
siteSettings.value?.signupBlockedUsernames || []
)
if (blockedMatch) {
errors.username = formatSignupBlockedUsernameMessage(blockedMatch)
valid = false
}
}
if (!form.email.trim()) {
@@ -205,7 +220,11 @@ const goNextStep = async () => {
: '회원가입이 완료되었습니다. 잠시 후 홈으로 이동합니다.'
await navigateTo(createdAdmin.value ? '/admin' : '/')
} catch (error) {
submitErrorMessage.value = error?.data?.message || '회원가입에 실패했습니다.'
const message = error?.data?.message || '회원가입에 실패했습니다.'
submitErrorMessage.value = message
if (message.includes('사용할 수 없는 단어')) {
errors.username = message
}
} finally {
isSubmitting.value = false
}

View File

@@ -3,6 +3,7 @@ import { z } from 'zod'
import { getUserById, isUsernameTaken, updateMemberProfile } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { isManagedAvatarUrl, removeManagedAvatarAsset } from '../../utils/member-avatar'
import { assertSignupUsernameAllowed } from '../../utils/member-username-policy'
const updateProfileSchema = z.object({
username: z.string().trim().min(1).max(30),
@@ -25,6 +26,8 @@ export default defineEventHandler(async (event) => {
})
}
await assertSignupUsernameAllowed(parsedBody.data.username)
const taken = await isUsernameTaken({
username: parsedBody.data.username,
excludeUserId: session.userId

View File

@@ -7,6 +7,7 @@ import { setMemberSession } from '../../utils/member-auth'
import { setAdminSession } from '../../utils/admin-auth'
import { isResendConfigured } from '../../utils/resend-mail'
import { getRuntimeEnvValue } from '../../utils/runtime-env'
import { assertSignupUsernameAllowed } from '../../utils/member-username-policy'
const signupSchema = z.object({
username: z.string().trim().min(1),
@@ -50,6 +51,8 @@ export default defineEventHandler(async (event) => {
const otpRequired = isSignupOtpRequired(config, bootstrap)
const emailNorm = body.email.trim().toLowerCase()
await assertSignupUsernameAllowed(body.username)
const usernameTaken = await isUsernameTaken({
username: body.username
})

View File

@@ -9,6 +9,10 @@ import { getDefaultNavigationItems } from '../utils/navigation-items'
import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree'
import { getDefaultSiteSettings } from '../utils/site-settings'
import { toAdminPostFormTitle } from '../../lib/admin-post-title.js'
import {
normalizeSignupBlockedUsernames,
parseSignupBlockedUsernamesFromDb
} from '../../lib/signup-blocked-usernames.js'
import { getPostgresClient } from './postgres-client'
/**
@@ -97,6 +101,11 @@ const mapSiteSettingsRow = (row) => ({
homeCoverImageUrl: row.home_cover_image_url || '',
homeCoverTitle: row.home_cover_title || '',
homeCoverText: row.home_cover_text || '',
announcementEnabled: Boolean(row.announcement_enabled),
announcementText: row.announcement_text || '',
announcementUrl: row.announcement_url || '',
announcementBackgroundColor: row.announcement_background_color || '#15171a',
signupBlockedUsernames: parseSignupBlockedUsernamesFromDb(row.signup_blocked_usernames),
updatedAt: row.updated_at.toISOString()
})
@@ -816,6 +825,11 @@ export const updateSiteSettings = async (input) => {
home_cover_image_url,
home_cover_title,
home_cover_text,
announcement_enabled,
announcement_text,
announcement_url,
announcement_background_color,
signup_blocked_usernames,
updated_at
)
VALUES (
@@ -831,6 +845,11 @@ export const updateSiteSettings = async (input) => {
${input.homeCoverImageUrl || ''},
${input.homeCoverTitle || ''},
${input.homeCoverText || ''},
${input.announcementEnabled ? true : false},
${input.announcementText || ''},
${input.announcementUrl || ''},
${input.announcementBackgroundColor || '#15171a'},
${JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))},
now()
)
ON CONFLICT (id) DO UPDATE
@@ -846,6 +865,11 @@ export const updateSiteSettings = async (input) => {
home_cover_image_url = EXCLUDED.home_cover_image_url,
home_cover_title = EXCLUDED.home_cover_title,
home_cover_text = EXCLUDED.home_cover_text,
announcement_enabled = EXCLUDED.announcement_enabled,
announcement_text = EXCLUDED.announcement_text,
announcement_url = EXCLUDED.announcement_url,
announcement_background_color = EXCLUDED.announcement_background_color,
signup_blocked_usernames = EXCLUDED.signup_blocked_usernames,
updated_at = now()
RETURNING *
`

View File

@@ -1,4 +1,15 @@
import { z } from 'zod'
import {
DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR,
isValidAnnouncementBackgroundColor,
normalizeAnnouncementUrl
} from '../../lib/announcement-bar.js'
import {
DEFAULT_SIGNUP_BLOCKED_USERNAMES,
MAX_SIGNUP_BLOCKED_USERNAME_COUNT,
MAX_SIGNUP_BLOCKED_USERNAME_LENGTH,
normalizeSignupBlockedUsernames
} from '../../lib/signup-blocked-usernames.js'
export const adminSiteSettingsInputSchema = z.object({
title: z.string().trim().min(1),
@@ -11,8 +22,35 @@ export const adminSiteSettingsInputSchema = z.object({
showPostUpdatedAt: z.boolean().optional().default(false),
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
homeCoverTitle: z.string().trim().max(120).optional().default(''),
homeCoverText: z.string().trim().max(280).optional().default('')
})
homeCoverText: z.string().trim().max(280).optional().default(''),
announcementEnabled: z.boolean().optional().default(false),
announcementText: z.string().trim().max(200).optional().default(''),
announcementUrl: z.string().trim().max(500).optional().default(''),
announcementBackgroundColor: z.string().trim().optional().default(DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
signupBlockedUsernames: z.array(
z.string().trim().min(1).max(MAX_SIGNUP_BLOCKED_USERNAME_LENGTH)
).max(MAX_SIGNUP_BLOCKED_USERNAME_COUNT).optional().default([...DEFAULT_SIGNUP_BLOCKED_USERNAMES])
}).superRefine((data, ctx) => {
if (!isValidAnnouncementBackgroundColor(data.announcementBackgroundColor)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '어나운스 바 배경색이 올바르지 않습니다.',
path: ['announcementBackgroundColor']
})
}
if (data.announcementEnabled && !data.announcementText.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '어나운스 바를 사용할 때는 공지 문구를 입력해야 합니다.',
path: ['announcementText']
})
}
}).transform((data) => ({
...data,
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
signupBlockedUsernames: normalizeSignupBlockedUsernames(data.signupBlockedUsernames)
}))
/**
* 관리자 사이트 설정 입력값 정리

View File

@@ -0,0 +1,23 @@
import { createError } from 'h3'
import {
formatSignupBlockedUsernameMessage,
getSignupBlockedUsernameMatch
} from '../../lib/signup-blocked-usernames.js'
import { getSiteSettings } from '../repositories/content-repository.js'
/**
* 가입·프로필 변경 시 닉네임이 금지 목록에 해당하는지 검사한다.
* @param {string} username - 닉네임
* @returns {Promise<void>}
*/
export const assertSignupUsernameAllowed = async (username) => {
const settings = await getSiteSettings()
const match = getSignupBlockedUsernameMatch(username, settings.signupBlockedUsernames || [])
if (match) {
throw createError({
statusCode: 400,
message: formatSignupBlockedUsernameMessage(match)
})
}
}

View File

@@ -1,3 +1,5 @@
import { DEFAULT_SIGNUP_BLOCKED_USERNAMES } from '../../lib/signup-blocked-usernames.js'
/**
* 기본 사이트 설정 반환
* @returns {Object} 기본 사이트 설정
@@ -18,6 +20,11 @@ export const getDefaultSiteSettings = () => {
homeCoverImageUrl: '',
homeCoverTitle: '',
homeCoverText: '',
announcementEnabled: false,
announcementText: '',
announcementUrl: '',
announcementBackgroundColor: '#15171a',
signupBlockedUsernames: [...DEFAULT_SIGNUP_BLOCKED_USERNAMES],
updatedAt: null
}
}