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

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}은 사용할 수 없는 단어입니다.`