브랜드 컬러 설정 추가 v1.5.36
This commit is contained in:
11
app.vue
11
app.vue
@@ -1,14 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from './lib/brand-color.js'
|
||||||
|
|
||||||
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
|
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
|
||||||
key: 'site-settings-public',
|
key: 'site-settings-public',
|
||||||
default: () => ({
|
default: () => ({
|
||||||
title: 'sori.studio',
|
title: 'sori.studio',
|
||||||
faviconUrl: '',
|
faviconUrl: '',
|
||||||
logoUrl: '',
|
logoUrl: '',
|
||||||
logoText: '井'
|
logoText: '井',
|
||||||
|
brandColor: DEFAULT_BRAND_COLOR
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const siteAccentStyle = computed(() => ({
|
||||||
|
'--site-accent': normalizeBrandColor(appSiteSettings.value?.brandColor || DEFAULT_BRAND_COLOR)
|
||||||
|
}))
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
titleTemplate: (titleChunk) => titleChunk
|
titleTemplate: (titleChunk) => titleChunk
|
||||||
? `${titleChunk} · ${appSiteSettings.value.title}`
|
? `${titleChunk} · ${appSiteSettings.value.title}`
|
||||||
@@ -26,7 +33,9 @@ useHead(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="site-app" :style="siteAccentStyle">
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||||
|
import { CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/** 패널 표시 여부 */
|
/** 패널 표시 여부 */
|
||||||
@@ -22,9 +23,30 @@ const emit = defineEmits([
|
|||||||
'move-gallery-image',
|
'move-gallery-image',
|
||||||
'remove-media-image',
|
'remove-media-image',
|
||||||
'add-gallery-images',
|
'add-gallery-images',
|
||||||
'update-embed-url'
|
'update-embed-url',
|
||||||
|
'update-quote-background'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const quoteBackgroundLabels = {
|
||||||
|
gray: '회색',
|
||||||
|
blue: '파랑',
|
||||||
|
green: '초록',
|
||||||
|
yellow: '노랑',
|
||||||
|
red: '빨강',
|
||||||
|
purple: '보라',
|
||||||
|
pink: '분홍'
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteBackgroundSwatches = {
|
||||||
|
gray: 'rgba(100,116,139,0.28)',
|
||||||
|
blue: 'rgba(59,130,246,0.3)',
|
||||||
|
green: 'rgba(34,197,94,0.3)',
|
||||||
|
yellow: 'rgba(245,158,11,0.34)',
|
||||||
|
red: 'rgba(239,68,68,0.3)',
|
||||||
|
purple: 'rgba(168,85,247,0.3)',
|
||||||
|
pink: 'rgba(236,72,153,0.28)'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 블록 종류 라벨
|
* 블록 종류 라벨
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
@@ -42,6 +64,10 @@ const panelTitle = computed(() => {
|
|||||||
return '임베드'
|
return '임베드'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'quote') {
|
||||||
|
return '인용'
|
||||||
|
}
|
||||||
|
|
||||||
return '이미지'
|
return '이미지'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,6 +88,10 @@ const panelMeta = computed(() => {
|
|||||||
return 'YouTube·X 등 URL'
|
return 'YouTube·X 등 URL'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'quote') {
|
||||||
|
return '인용 배경색'
|
||||||
|
}
|
||||||
|
|
||||||
return '커서가 위치한 이미지 줄'
|
return '커서가 위치한 이미지 줄'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,6 +157,36 @@ const onPanelFocusOut = (event) => {
|
|||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="panel.kind === 'quote'">
|
||||||
|
<div class="admin-editor-block-panel__quote-settings grid gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-[#394047]">
|
||||||
|
배경색
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-[#8e9cac]">
|
||||||
|
현재 인용 블록의 첫 줄에 배경 옵션을 추가합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="background in CALLOUT_BACKGROUND_OPTIONS"
|
||||||
|
:key="`quote-background-${background}`"
|
||||||
|
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
|
||||||
|
:class="panel.quoteBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
||||||
|
type="button"
|
||||||
|
@click="emit('update-quote-background', background)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-5 shrink-0 rounded-full border border-black/5"
|
||||||
|
:style="{ background: quoteBackgroundSwatches[background] }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ quoteBackgroundLabels[background] || background }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="admin-editor-block-panel__media-list grid gap-3">
|
<div class="admin-editor-block-panel__media-list grid gap-3">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
parseSlashInput,
|
parseSlashInput,
|
||||||
resolveSlashCommand
|
resolveSlashCommand
|
||||||
} from '../../lib/markdown-slash-commands.js'
|
} from '../../lib/markdown-slash-commands.js'
|
||||||
|
import { CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
|
||||||
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
||||||
import {
|
import {
|
||||||
buildDefaultUploadSizeLimits,
|
buildDefaultUploadSizeLimits,
|
||||||
@@ -170,7 +171,7 @@ const closeBlockPanel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드)
|
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용)
|
||||||
* @returns {Object|null}
|
* @returns {Object|null}
|
||||||
*/
|
*/
|
||||||
const activeBlockContext = computed(() => resolveActiveBlockContext(
|
const activeBlockContext = computed(() => resolveActiveBlockContext(
|
||||||
@@ -2074,6 +2075,38 @@ const updateActiveEmbedUrl = (url) => {
|
|||||||
replaceLineRange(block.startLine, block.endLine, [trimmed], false)
|
replaceLineRange(block.startLine, block.endLine, [trimmed], false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 인용 블록 배경색을 수정한다.
|
||||||
|
* @param {string} background - 배경색 옵션
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateActiveQuoteBackground = (background) => {
|
||||||
|
const block = activeBlockContext.value
|
||||||
|
|
||||||
|
if (!block || block.kind !== 'quote') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureBlockPanelEngaged()
|
||||||
|
const value = String(background || '').trim()
|
||||||
|
|
||||||
|
if (!CALLOUT_BACKGROUND_OPTIONS.includes(value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionLine = `> [!bg=${value}]`
|
||||||
|
const lines = (markdownValue.value || '').split('\n')
|
||||||
|
const nextLines = [...lines]
|
||||||
|
|
||||||
|
if (block.hasQuoteOptions) {
|
||||||
|
nextLines.splice(block.startLine, 1, optionLine)
|
||||||
|
} else {
|
||||||
|
nextLines.splice(block.startLine, 0, optionLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownValue.value = nextLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 마크다운을 삽입한다.
|
* 이미지 마크다운을 삽입한다.
|
||||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||||
@@ -2140,6 +2173,7 @@ defineExpose({
|
|||||||
removeActiveMediaImage,
|
removeActiveMediaImage,
|
||||||
appendImagesToActiveGallery,
|
appendImagesToActiveGallery,
|
||||||
updateActiveEmbedUrl,
|
updateActiveEmbedUrl,
|
||||||
|
updateActiveQuoteBackground,
|
||||||
openMediaPicker
|
openMediaPicker
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1053,6 +1053,15 @@ const onBlockPanelUpdateEmbedUrl = (url) => {
|
|||||||
blockEditor.value?.updateActiveEmbedUrl?.(url)
|
blockEditor.value?.updateActiveEmbedUrl?.(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 패널에서 인용 배경색을 수정한다.
|
||||||
|
* @param {string} background - 인용 배경색
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBlockPanelUpdateQuoteBackground = (background) => {
|
||||||
|
blockEditor.value?.updateActiveQuoteBackground?.(background)
|
||||||
|
}
|
||||||
|
|
||||||
const focusContentEditor = (event) => {
|
const focusContentEditor = (event) => {
|
||||||
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
||||||
return
|
return
|
||||||
@@ -1874,6 +1883,7 @@ defineExpose({
|
|||||||
@remove-media-image="onBlockPanelRemoveMediaImage"
|
@remove-media-image="onBlockPanelRemoveMediaImage"
|
||||||
@add-gallery-images="onBlockPanelAddGalleryImages"
|
@add-gallery-images="onBlockPanelAddGalleryImages"
|
||||||
@update-embed-url="onBlockPanelUpdateEmbedUrl"
|
@update-embed-url="onBlockPanelUpdateEmbedUrl"
|
||||||
|
@update-quote-background="onBlockPanelUpdateQuoteBackground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
2
db/migrations/046_site_settings_brand_color.sql
Normal file
2
db/migrations/046_site_settings_brand_color.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS brand_color TEXT NOT NULL DEFAULT '#ff4f2e';
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.5.36
|
||||||
|
|
||||||
|
- 관리자 사이트 설정에서 브랜드 포인트 컬러를 지정할 수 있게 했다.
|
||||||
|
- 지정한 브랜드 컬러가 공개 화면의 활성 네비게이션, TOC, 댓글 버튼 등에 반영된다.
|
||||||
|
- 게시물 글쓰기에서 인용문 블록 배경색을 오른쪽 블록 패널로 선택할 수 있게 했다.
|
||||||
|
|
||||||
## v1.5.35
|
## v1.5.35
|
||||||
|
|
||||||
- 관리자 대시보드에 방문자 유입 정보, 디바이스 통계, 유입 키워드 영역을 추가했다.
|
- 관리자 대시보드에 방문자 유입 정보, 디바이스 통계, 유입 키워드 영역을 추가했다.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-06-02 v1.5.36 — 포인트 컬러는 테마 파일이 아니라 운영 설정이다
|
||||||
|
|
||||||
|
사용자 화면의 활성 네비게이션, TOC, 댓글 버튼은 사이트의 브랜드 인상을 만드는 공통 강조 요소다. 코드에 고정된 오렌지색을 각 컴포넌트에서 따로 바꾸면 운영자가 브랜드를 바꾸기 어렵고 누락도 생기므로, 사이트 설정의 `brand_color`를 공개 앱 루트의 `--site-accent` CSS 변수로 주입한다. 기존 색상은 기본값으로 유지해 저장값이 없거나 마이그레이션 전 환경에서도 같은 화면을 보여 준다.
|
||||||
|
|
||||||
## 2026-06-02 v1.5.35 — 유입 분석은 축약 집계로 시작한다
|
## 2026-06-02 v1.5.35 — 유입 분석은 축약 집계로 시작한다
|
||||||
|
|
||||||
관리자 대시보드는 방문자가 어디서 들어왔는지, 어떤 기기로 보는지, 검색 키워드가 있는지 정도를 빠르게 파악할 수 있어야 한다. 다만 원문 referrer나 IP를 오래 저장하면 운영 부담과 개인정보 리스크가 커지므로, 페이지뷰 요청 시 서버가 즉시 검색·SNS·직접·기타, 디바이스·OS, 키워드로 축약해 일별 집계만 남긴다. 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 수집되며, 숨겨진 키워드는 소급하거나 추정하지 않는다.
|
관리자 대시보드는 방문자가 어디서 들어왔는지, 어떤 기기로 보는지, 검색 키워드가 있는지 정도를 빠르게 파악할 수 있어야 한다. 다만 원문 referrer나 IP를 오래 저장하면 운영 부담과 개인정보 리스크가 커지므로, 페이지뷰 요청 시 서버가 즉시 검색·SNS·직접·기타, 디바이스·OS, 키워드로 축약해 일별 집계만 남긴다. 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 수집되며, 숨겨진 키워드는 소급하거나 추정하지 않는다.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
||||||
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 `/admin` 활성 링크·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
|
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 `/admin` 활성 링크·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
|
||||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||||
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
|
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크, 기본 title template, 브랜드 컬러 `--site-accent` 적용 |
|
||||||
| error.vue | 공개 404/서버 오류 화면, 홈 이동 버튼 |
|
| error.vue | 공개 404/서버 오류 화면, 홈 이동 버튼 |
|
||||||
|
|
||||||
## Composables
|
## Composables
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
|
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
|
||||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||||
| lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드 블록 설정 패널 대상 판별 |
|
| lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드·인용 블록 설정 패널 대상 판별 |
|
||||||
|
| lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 |
|
||||||
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
|
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
|
||||||
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
|
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
|
||||||
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
|
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
|
||||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
|
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
|
||||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 |
|
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색), 갤러리 선택 이미지 강조 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
|
|
||||||
- 라이트/다크 모드는 CSS 변수로 관리
|
- 라이트/다크 모드는 CSS 변수로 관리
|
||||||
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
||||||
|
- 브랜드 포인트 컬러는 사이트 설정의 `brandColor` 값을 공개 앱 루트의 `--site-accent` CSS 변수로 반영한다. 기본값은 `#ff4f2e`이며, 왼쪽 사이드 활성 네비게이션, 게시글 TOC 활성 항목, 댓글 등록 버튼 등 사용자 화면의 주요 강조 요소에 사용한다.
|
||||||
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
||||||
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고 이미지 URL(`SITE_BRAND_LOGO_URL`, localStorage) 또는 사이트 제목(`NUXT_PUBLIC_SITE_TITLE`)을 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다. `site_settings.logo_text`(기본 `井`)는 **이미지 로고가 없을 때** 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다.
|
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고 이미지 URL(`SITE_BRAND_LOGO_URL`, localStorage) 또는 사이트 제목(`NUXT_PUBLIC_SITE_TITLE`)을 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다. `site_settings.logo_text`(기본 `井`)는 **이미지 로고가 없을 때** 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다.
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
- 댓글 정렬은 `인기순`(좋아요 우선), `최신순`, `오래된순`을 제공한다.
|
- 댓글 정렬은 `인기순`(좋아요 우선), `최신순`, `오래된순`을 제공한다.
|
||||||
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||||
|
- 인용문(`>`)은 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`로 배경색을 지정할 수 있으며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`이다.
|
||||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다.
|
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다.
|
||||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||||
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
|
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.5.36
|
||||||
|
|
||||||
|
- 관리자 사이트 설정: 메인 화면 아래 브랜드 컬러 설정 카드 추가.
|
||||||
|
- 공개 화면: 사이트 설정의 브랜드 컬러를 `--site-accent`에 반영하도록 수정.
|
||||||
|
- 게시물 글쓰기: 인용문(`>`) 블록에서도 배경색 프리셋을 선택할 수 있도록 추가.
|
||||||
|
- DB: `site_settings.brand_color` 컬럼 추가.
|
||||||
|
|
||||||
## v1.5.35
|
## v1.5.35
|
||||||
|
|
||||||
- 관리자 대시보드: 검색·SNS·직접·기타 유입 정보 카드 추가.
|
- 관리자 대시보드: 검색·SNS·직접·기타 유입 정보 카드 추가.
|
||||||
|
|||||||
27
lib/brand-color.js
Normal file
27
lib/brand-color.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export const DEFAULT_BRAND_COLOR = '#ff4f2e'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브랜드 컬러 형식이 올바른지 확인한다.
|
||||||
|
* @param {unknown} value - 검사할 값
|
||||||
|
* @returns {boolean} 유효한 색상 여부
|
||||||
|
*/
|
||||||
|
export const isValidBrandColor = (value) => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '').trim())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브랜드 컬러를 6자리 hex 값으로 정규화한다.
|
||||||
|
* @param {unknown} value - 정규화할 색상 값
|
||||||
|
* @returns {string} 정규화된 색상
|
||||||
|
*/
|
||||||
|
export const normalizeBrandColor = (value) => {
|
||||||
|
const color = String(value || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!isValidBrandColor(color)) {
|
||||||
|
return DEFAULT_BRAND_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.length === 4) {
|
||||||
|
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return color
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
|
import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
|
||||||
|
import { CALLOUT_BACKGROUND_OPTIONS } from './markdown-callout.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fenced 블록 시작 줄 인덱스를 찾는다.
|
* fenced 블록 시작 줄 인덱스를 찾는다.
|
||||||
@@ -51,6 +52,87 @@ const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || ''
|
|||||||
*/
|
*/
|
||||||
const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageUrl(line)
|
const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageUrl(line)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인용 마커 줄인지 확인한다.
|
||||||
|
* @param {string} line - 마크다운 줄
|
||||||
|
* @returns {boolean} 인용 줄 여부
|
||||||
|
*/
|
||||||
|
const isQuoteMarkerLine = (line) => {
|
||||||
|
const trimmed = String(line ?? '').trim()
|
||||||
|
return trimmed === '>' || /^>\s/.test(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인용 마커를 제거한 본문을 반환한다.
|
||||||
|
* @param {string} line - 마크다운 줄
|
||||||
|
* @returns {string} 인용 본문
|
||||||
|
*/
|
||||||
|
const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인용 옵션 줄을 파싱한다.
|
||||||
|
* @param {string} value - 인용 본문 줄
|
||||||
|
* @returns {{ quoteBackground: string }|null} 인용 옵션
|
||||||
|
*/
|
||||||
|
const parseQuoteOptionsLine = (value) => {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
const bracketMatch = raw.match(/^\[!(.+)\]$/)
|
||||||
|
const braceMatch = raw.match(/^\{(.+)\}$/)
|
||||||
|
const optionSource = bracketMatch?.[1] || braceMatch?.[1] || ''
|
||||||
|
|
||||||
|
if (!optionSource) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = optionSource.trim().split(/\s+/)
|
||||||
|
let quoteBackground = ''
|
||||||
|
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
const [key, rawOptionValue] = token.split('=')
|
||||||
|
const optionValue = String(rawOptionValue || '').trim()
|
||||||
|
|
||||||
|
if (key?.toLowerCase() === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(optionValue)) {
|
||||||
|
quoteBackground = optionValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return quoteBackground ? { quoteBackground } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인용 블록을 파싱한다.
|
||||||
|
* @param {string[]} lines - 본문 줄 목록
|
||||||
|
* @param {number} currentLine - 현재 줄
|
||||||
|
* @returns {{ kind: 'quote', startLine: number, endLine: number, quoteBackground: string, hasQuoteOptions: boolean }|null}
|
||||||
|
*/
|
||||||
|
const resolveQuoteBlock = (lines, currentLine) => {
|
||||||
|
if (!isQuoteMarkerLine(lines[currentLine] || '')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let startLine = currentLine
|
||||||
|
let endLine = currentLine
|
||||||
|
|
||||||
|
while (startLine > 0 && isQuoteMarkerLine(lines[startLine - 1] || '')) {
|
||||||
|
startLine -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while (endLine < lines.length - 1 && isQuoteMarkerLine(lines[endLine + 1] || '')) {
|
||||||
|
endLine += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstQuoteBody = getQuoteLineBody(lines[startLine] || '')
|
||||||
|
const quoteOptions = parseQuoteOptionsLine(firstQuoteBody)
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'quote',
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
quoteBackground: quoteOptions?.quoteBackground || 'pink',
|
||||||
|
hasQuoteOptions: Boolean(quoteOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 갤러리 fenced 블록을 파싱한다.
|
* 갤러리 fenced 블록을 파싱한다.
|
||||||
* @param {string[]} lines - 본문 줄 목록
|
* @param {string[]} lines - 본문 줄 목록
|
||||||
@@ -158,5 +240,11 @@ export const resolveActiveBlockContext = (markdown, lineIndex) => {
|
|||||||
return gallery
|
return gallery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quote = resolveQuoteBlock(lines, currentLine)
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
return quote
|
||||||
|
}
|
||||||
|
|
||||||
return resolveEmbedBlock(lines, currentLine)
|
return resolveEmbedBlock(lines, currentLine)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.35",
|
"version": "1.5.36",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ANNOUNCEMENT_BACKGROUND_PRESETS } from '~/lib/announcement-bar.js'
|
import { ANNOUNCEMENT_BACKGROUND_PRESETS } from '~/lib/announcement-bar.js'
|
||||||
|
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from '~/lib/brand-color.js'
|
||||||
import {
|
import {
|
||||||
normalizeSignupBlockedUsernames,
|
normalizeSignupBlockedUsernames,
|
||||||
parseSignupBlockedUsernamesFromText
|
parseSignupBlockedUsernamesFromText
|
||||||
@@ -15,6 +16,7 @@ const savingTitleDesc = ref(false)
|
|||||||
const savingMisc = ref(false)
|
const savingMisc = ref(false)
|
||||||
const savingPost = ref(false)
|
const savingPost = ref(false)
|
||||||
const savingHomeCover = ref(false)
|
const savingHomeCover = ref(false)
|
||||||
|
const savingBrand = ref(false)
|
||||||
const savingAnnouncement = ref(false)
|
const savingAnnouncement = ref(false)
|
||||||
const savingSpam = ref(false)
|
const savingSpam = ref(false)
|
||||||
const savingSiteCode = ref(false)
|
const savingSiteCode = ref(false)
|
||||||
@@ -56,6 +58,8 @@ const editMisc = ref(false)
|
|||||||
const editPost = ref(false)
|
const editPost = ref(false)
|
||||||
/** 메인 화면 커버 카드 편집 모드 여부 */
|
/** 메인 화면 커버 카드 편집 모드 여부 */
|
||||||
const editHomeCover = ref(false)
|
const editHomeCover = ref(false)
|
||||||
|
/** 브랜드 컬러 카드 편집 모드 여부 */
|
||||||
|
const editBrand = ref(false)
|
||||||
/** 어나운스 바 맞춤 설정 패널 열림 여부 */
|
/** 어나운스 바 맞춤 설정 패널 열림 여부 */
|
||||||
const customizeAnnouncement = ref(false)
|
const customizeAnnouncement = ref(false)
|
||||||
/** 스팸 필터 카드 편집 모드 여부 */
|
/** 스팸 필터 카드 편집 모드 여부 */
|
||||||
@@ -86,6 +90,10 @@ const homeCoverSnapshot = reactive({
|
|||||||
homeCoverTitle: '',
|
homeCoverTitle: '',
|
||||||
homeCoverText: ''
|
homeCoverText: ''
|
||||||
})
|
})
|
||||||
|
/** 편집 시작 시점의 브랜드 설정(취소 시 복원용) */
|
||||||
|
const brandSnapshot = reactive({
|
||||||
|
brandColor: DEFAULT_BRAND_COLOR
|
||||||
|
})
|
||||||
/** 맞춤 설정 시작 시점의 어나운스 바(취소 시 복원용) */
|
/** 맞춤 설정 시작 시점의 어나운스 바(취소 시 복원용) */
|
||||||
const announcementSnapshot = reactive({
|
const announcementSnapshot = reactive({
|
||||||
announcementEnabled: false,
|
announcementEnabled: false,
|
||||||
@@ -128,6 +136,7 @@ const form = reactive({
|
|||||||
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
|
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
|
||||||
homeCoverTitle: settings.value?.homeCoverTitle || '',
|
homeCoverTitle: settings.value?.homeCoverTitle || '',
|
||||||
homeCoverText: settings.value?.homeCoverText || '',
|
homeCoverText: settings.value?.homeCoverText || '',
|
||||||
|
brandColor: normalizeBrandColor(settings.value?.brandColor || DEFAULT_BRAND_COLOR),
|
||||||
announcementEnabled: Boolean(settings.value?.announcementEnabled),
|
announcementEnabled: Boolean(settings.value?.announcementEnabled),
|
||||||
announcementText: settings.value?.announcementText || '',
|
announcementText: settings.value?.announcementText || '',
|
||||||
announcementUrl: settings.value?.announcementUrl || '',
|
announcementUrl: settings.value?.announcementUrl || '',
|
||||||
@@ -177,6 +186,13 @@ const hasHomeCoverChanges = computed(() => editHomeCover.value && (
|
|||||||
|| form.homeCoverText !== homeCoverSnapshot.homeCoverText
|
|| form.homeCoverText !== homeCoverSnapshot.homeCoverText
|
||||||
))
|
))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브랜드 설정 변경 여부
|
||||||
|
* @returns {boolean} 변경 여부
|
||||||
|
*/
|
||||||
|
const hasBrandChanges = computed(() => editBrand.value
|
||||||
|
&& normalizeBrandColor(form.brandColor) !== normalizeBrandColor(brandSnapshot.brandColor))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 어나운스 바 변경 여부
|
* 어나운스 바 변경 여부
|
||||||
* @returns {boolean} 변경 여부
|
* @returns {boolean} 변경 여부
|
||||||
@@ -471,6 +487,7 @@ const settingsNavGroups = [
|
|||||||
heading: '사이트',
|
heading: '사이트',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image', iconId: 'home-cover' },
|
{ id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image', iconId: 'home-cover' },
|
||||||
|
{ id: 'admin-settings-section-brand', label: '브랜드', keywords: 'brand design accent color point 포인트 컬러', iconId: 'site-code' },
|
||||||
{ id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice', iconId: 'announcement' },
|
{ id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice', iconId: 'announcement' },
|
||||||
{ id: 'admin-settings-section-site-code', label: '사이트 코드', keywords: 'ads ads.txt head footer script code adsense', iconId: 'site-code' }
|
{ id: 'admin-settings-section-site-code', label: '사이트 코드', keywords: 'ads ads.txt head footer script code adsense', iconId: 'site-code' }
|
||||||
]
|
]
|
||||||
@@ -1042,6 +1059,7 @@ const buildSiteSettingsPayload = () => ({
|
|||||||
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
|
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
|
||||||
homeCoverTitle: form.homeCoverTitle || '',
|
homeCoverTitle: form.homeCoverTitle || '',
|
||||||
homeCoverText: form.homeCoverText || '',
|
homeCoverText: form.homeCoverText || '',
|
||||||
|
brandColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR),
|
||||||
announcementEnabled: Boolean(form.announcementEnabled),
|
announcementEnabled: Boolean(form.announcementEnabled),
|
||||||
announcementText: form.announcementText || '',
|
announcementText: form.announcementText || '',
|
||||||
announcementUrl: form.announcementUrl || '',
|
announcementUrl: form.announcementUrl || '',
|
||||||
@@ -1369,6 +1387,55 @@ const saveHomeCoverSection = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브랜드 설정 편집 모드 진입
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const beginEditBrand = () => {
|
||||||
|
brandSnapshot.brandColor = normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR)
|
||||||
|
form.brandColor = brandSnapshot.brandColor
|
||||||
|
editBrand.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브랜드 설정 편집 취소
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const cancelEditBrand = () => {
|
||||||
|
form.brandColor = brandSnapshot.brandColor
|
||||||
|
editBrand.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브랜드 컬러 입력값을 정규화한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const normalizeBrandColorInput = () => {
|
||||||
|
form.brandColor = normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브랜드 설정 저장
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const saveBrandSection = async () => {
|
||||||
|
normalizeBrandColorInput()
|
||||||
|
|
||||||
|
if (!hasBrandChanges.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await persistSiteSettings({
|
||||||
|
successToast: '브랜드 컬러가 저장되었습니다.',
|
||||||
|
savingFlag: savingBrand
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
brandSnapshot.brandColor = normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR)
|
||||||
|
editBrand.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 어나운스 바 맞춤 설정 패널을 연다.
|
* 어나운스 바 맞춤 설정 패널을 연다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -2310,6 +2377,129 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="admin-settings-section-brand"
|
||||||
|
class="admin-settings-screen__card admin-settings-screen__card--brand 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="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="!editBrand"
|
||||||
|
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||||
|
>
|
||||||
|
사용자 화면의 포인트 컬러입니다. 활성 네비게이션, TOC, 댓글 버튼 등에 공통으로 적용됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||||
|
<template v-if="!editBrand">
|
||||||
|
<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="beginEditBrand"
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</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="savingBrand"
|
||||||
|
@click="cancelEditBrand"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</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="savingBrand || !hasBrandChanges"
|
||||||
|
@click="saveBrandSection"
|
||||||
|
>
|
||||||
|
{{ savingBrand ? '저장 중' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 border-t border-[#eceff2] pt-5">
|
||||||
|
<div
|
||||||
|
v-if="!editBrand"
|
||||||
|
class="admin-settings-screen__brand-readonly flex flex-col gap-4 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="size-12 shrink-0 rounded-full border border-black/10"
|
||||||
|
:style="{ backgroundColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR) }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-[#15171a]">
|
||||||
|
포인트 컬러
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-mono text-sm text-[#657080]">
|
||||||
|
{{ normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full px-3 py-1 text-xs font-bold text-white"
|
||||||
|
:style="{ backgroundColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR) }"
|
||||||
|
>
|
||||||
|
활성 배지
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex border-b-2 px-1 py-1 text-xs font-bold"
|
||||||
|
:style="{ borderColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR), color: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR) }"
|
||||||
|
>
|
||||||
|
TOC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="admin-settings-screen__brand-edit grid gap-4 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-4">
|
||||||
|
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||||
|
<span class="font-medium text-[#3f4650]">브랜드 컬러</span>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<input
|
||||||
|
class="h-12 w-full rounded-md border border-[#dce0e5] bg-white px-2 py-1 sm:w-20"
|
||||||
|
type="color"
|
||||||
|
:value="normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR)"
|
||||||
|
@input="form.brandColor = $event.target.value"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.brandColor"
|
||||||
|
class="h-12 min-w-0 flex-1 rounded-md border border-[#dce0e5] bg-white px-3 font-mono text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
type="text"
|
||||||
|
placeholder="#ff4f2e"
|
||||||
|
@blur="normalizeBrandColorInput"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p class="text-sm leading-relaxed text-[#657080]">
|
||||||
|
3자리 또는 6자리 hex 컬러를 사용할 수 있습니다. 비워두거나 잘못된 값은 기본 오렌지 컬러로 되돌립니다.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-10 items-center rounded-full px-4 text-sm font-bold text-white"
|
||||||
|
:style="{ backgroundColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR) }"
|
||||||
|
>
|
||||||
|
댓글 등록
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex h-10 items-center border-b-2 px-1 text-sm font-bold"
|
||||||
|
:style="{ borderColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR), color: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR) }"
|
||||||
|
>
|
||||||
|
활성 TOC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="admin-settings-section-announcement"
|
id="admin-settings-section-announcement"
|
||||||
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"
|
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"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getDefaultNavigationItems } from '../utils/navigation-items'
|
|||||||
import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree'
|
import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree'
|
||||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||||
import { toAdminPostFormTitle } from '../../lib/admin-post-title.js'
|
import { toAdminPostFormTitle } from '../../lib/admin-post-title.js'
|
||||||
|
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from '../../lib/brand-color.js'
|
||||||
import {
|
import {
|
||||||
normalizeSignupBlockedUsernames,
|
normalizeSignupBlockedUsernames,
|
||||||
parseSignupBlockedUsernamesFromDb
|
parseSignupBlockedUsernamesFromDb
|
||||||
@@ -105,6 +106,7 @@ const mapSiteSettingsRow = (row) => ({
|
|||||||
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
|
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
|
||||||
homeCoverTitle: row.home_cover_title || '',
|
homeCoverTitle: row.home_cover_title || '',
|
||||||
homeCoverText: row.home_cover_text || '',
|
homeCoverText: row.home_cover_text || '',
|
||||||
|
brandColor: normalizeBrandColor(row.brand_color || DEFAULT_BRAND_COLOR),
|
||||||
announcementEnabled: Boolean(row.announcement_enabled),
|
announcementEnabled: Boolean(row.announcement_enabled),
|
||||||
announcementText: row.announcement_text || '',
|
announcementText: row.announcement_text || '',
|
||||||
announcementUrl: row.announcement_url || '',
|
announcementUrl: row.announcement_url || '',
|
||||||
@@ -872,6 +874,7 @@ export const updateSiteSettings = async (input) => {
|
|||||||
home_cover_dark_image_url,
|
home_cover_dark_image_url,
|
||||||
home_cover_title,
|
home_cover_title,
|
||||||
home_cover_text,
|
home_cover_text,
|
||||||
|
brand_color,
|
||||||
announcement_enabled,
|
announcement_enabled,
|
||||||
announcement_text,
|
announcement_text,
|
||||||
announcement_url,
|
announcement_url,
|
||||||
@@ -896,6 +899,7 @@ export const updateSiteSettings = async (input) => {
|
|||||||
${input.homeCoverDarkImageUrl || ''},
|
${input.homeCoverDarkImageUrl || ''},
|
||||||
${input.homeCoverTitle || ''},
|
${input.homeCoverTitle || ''},
|
||||||
${input.homeCoverText || ''},
|
${input.homeCoverText || ''},
|
||||||
|
${normalizeBrandColor(input.brandColor)},
|
||||||
${input.announcementEnabled ? true : false},
|
${input.announcementEnabled ? true : false},
|
||||||
${input.announcementText || ''},
|
${input.announcementText || ''},
|
||||||
${input.announcementUrl || ''},
|
${input.announcementUrl || ''},
|
||||||
@@ -920,6 +924,7 @@ export const updateSiteSettings = async (input) => {
|
|||||||
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
|
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
|
||||||
home_cover_title = EXCLUDED.home_cover_title,
|
home_cover_title = EXCLUDED.home_cover_title,
|
||||||
home_cover_text = EXCLUDED.home_cover_text,
|
home_cover_text = EXCLUDED.home_cover_text,
|
||||||
|
brand_color = EXCLUDED.brand_color,
|
||||||
announcement_enabled = EXCLUDED.announcement_enabled,
|
announcement_enabled = EXCLUDED.announcement_enabled,
|
||||||
announcement_text = EXCLUDED.announcement_text,
|
announcement_text = EXCLUDED.announcement_text,
|
||||||
announcement_url = EXCLUDED.announcement_url,
|
announcement_url = EXCLUDED.announcement_url,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import {
|
|||||||
isValidAnnouncementBackgroundColor,
|
isValidAnnouncementBackgroundColor,
|
||||||
normalizeAnnouncementUrl
|
normalizeAnnouncementUrl
|
||||||
} from '../../lib/announcement-bar.js'
|
} from '../../lib/announcement-bar.js'
|
||||||
|
import {
|
||||||
|
DEFAULT_BRAND_COLOR,
|
||||||
|
isValidBrandColor,
|
||||||
|
normalizeBrandColor
|
||||||
|
} from '../../lib/brand-color.js'
|
||||||
import {
|
import {
|
||||||
DEFAULT_SIGNUP_BLOCKED_USERNAMES,
|
DEFAULT_SIGNUP_BLOCKED_USERNAMES,
|
||||||
MAX_SIGNUP_BLOCKED_USERNAME_COUNT,
|
MAX_SIGNUP_BLOCKED_USERNAME_COUNT,
|
||||||
@@ -24,6 +29,7 @@ export const adminSiteSettingsInputSchema = z.object({
|
|||||||
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
|
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
|
||||||
homeCoverTitle: z.string().trim().max(120).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(''),
|
||||||
|
brandColor: z.string().trim().optional().default(DEFAULT_BRAND_COLOR),
|
||||||
announcementEnabled: z.boolean().optional().default(false),
|
announcementEnabled: z.boolean().optional().default(false),
|
||||||
announcementText: z.string().trim().max(200).optional().default(''),
|
announcementText: z.string().trim().max(200).optional().default(''),
|
||||||
announcementUrl: z.string().trim().max(500).optional().default(''),
|
announcementUrl: z.string().trim().max(500).optional().default(''),
|
||||||
@@ -43,6 +49,14 @@ export const adminSiteSettingsInputSchema = z.object({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidBrandColor(data.brandColor)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: '브랜드 컬러가 올바르지 않습니다.',
|
||||||
|
path: ['brandColor']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (data.announcementEnabled && !data.announcementText.trim()) {
|
if (data.announcementEnabled && !data.announcementText.trim()) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@@ -52,6 +66,7 @@ export const adminSiteSettingsInputSchema = z.object({
|
|||||||
}
|
}
|
||||||
}).transform((data) => ({
|
}).transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
|
brandColor: normalizeBrandColor(data.brandColor),
|
||||||
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
|
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
|
||||||
signupBlockedUsernames: normalizeSignupBlockedUsernames(data.signupBlockedUsernames)
|
signupBlockedUsernames: normalizeSignupBlockedUsernames(data.signupBlockedUsernames)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DEFAULT_BRAND_COLOR } from '../../lib/brand-color.js'
|
||||||
import { DEFAULT_SIGNUP_BLOCKED_USERNAMES } from '../../lib/signup-blocked-usernames.js'
|
import { DEFAULT_SIGNUP_BLOCKED_USERNAMES } from '../../lib/signup-blocked-usernames.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +22,7 @@ export const getDefaultSiteSettings = () => {
|
|||||||
homeCoverDarkImageUrl: '',
|
homeCoverDarkImageUrl: '',
|
||||||
homeCoverTitle: '',
|
homeCoverTitle: '',
|
||||||
homeCoverText: '',
|
homeCoverText: '',
|
||||||
|
brandColor: DEFAULT_BRAND_COLOR,
|
||||||
announcementEnabled: false,
|
announcementEnabled: false,
|
||||||
announcementText: '',
|
announcementText: '',
|
||||||
announcementUrl: '',
|
announcementUrl: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user