게시물 인아티클 광고 슬롯 추가
This commit is contained in:
@@ -24,6 +24,12 @@ const adSlots = [
|
|||||||
description: '게시물 상세 본문 렌더링 직전에 표시됩니다.',
|
description: '게시물 상세 본문 렌더링 직전에 표시됩니다.',
|
||||||
placeholder: '<script async src="..."><' + '/script>\n<ins class="adsbygoogle" ...></ins>'
|
placeholder: '<script async src="..."><' + '/script>\n<ins class="adsbygoogle" ...></ins>'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'adPostInArticleCode',
|
||||||
|
label: '게시물 인아티클',
|
||||||
|
description: '게시물 본문이 충분히 길 때 전체 블록 40% 근처 문단 뒤에 한 번 표시됩니다.',
|
||||||
|
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'adPostBottomCode',
|
key: 'adPostBottomCode',
|
||||||
label: '게시물 본문 하단',
|
label: '게시물 본문 하단',
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const props = defineProps({
|
|||||||
slashSuppressedLines: {
|
slashSuppressedLines: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
/** 공개 게시물 본문 중간에 삽입할 인아티클 광고 코드 */
|
||||||
|
inArticleAdCode: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -757,6 +762,71 @@ const parseMarkdownBlocks = (markdown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||||
|
const normalizedInArticleAdCode = computed(() => String(props.inArticleAdCode || '').trim())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록의 텍스트 길이를 계산한다.
|
||||||
|
* @param {Object} block - 마크다운 블록
|
||||||
|
* @returns {number} 공백을 제외한 텍스트 길이
|
||||||
|
*/
|
||||||
|
const getBlockPlainTextLength = (block) => {
|
||||||
|
const value = Array.isArray(block.text) ? block.text.join(' ') : String(block.text || '')
|
||||||
|
return value.replace(/\s+/g, '').length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 인아티클 광고를 삽입할 수 있는 문단 블록인지 확인한다.
|
||||||
|
* @param {Object} block - 마크다운 블록
|
||||||
|
* @returns {boolean} 삽입 후보 여부
|
||||||
|
*/
|
||||||
|
const isInArticleAdCandidateBlock = (block) => {
|
||||||
|
return block?.type === 'paragraph' && getBlockPlainTextLength(block) >= 12
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 인아티클 광고를 삽입할 블록 ID를 반환한다.
|
||||||
|
* @returns {string} 삽입 기준 블록 ID
|
||||||
|
*/
|
||||||
|
const inArticleAdAfterBlockId = computed(() => {
|
||||||
|
if (props.interactive || !normalizedInArticleAdCode.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBlocks = blocks.value
|
||||||
|
const contentBlocks = currentBlocks.filter((block) => !['spacer', 'divider'].includes(block.type))
|
||||||
|
|
||||||
|
if (contentBlocks.length < 10) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = currentBlocks
|
||||||
|
.map((block, index) => ({ block, index }))
|
||||||
|
.filter((item) => isInArticleAdCandidateBlock(item.block))
|
||||||
|
|
||||||
|
if (candidates.length < 6) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const minIndex = Math.max(3, Math.floor(currentBlocks.length * 0.2))
|
||||||
|
const maxIndex = Math.min(currentBlocks.length - 4, Math.floor(currentBlocks.length * 0.75))
|
||||||
|
|
||||||
|
if (maxIndex < minIndex) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = Math.floor((currentBlocks.length - 1) * 0.4)
|
||||||
|
const rangedCandidates = candidates.filter((item) => item.index >= minIndex && item.index <= maxIndex)
|
||||||
|
|
||||||
|
if (!rangedCandidates.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = rangedCandidates.reduce((closest, item) => {
|
||||||
|
return Math.abs(item.index - targetIndex) < Math.abs(closest.index - targetIndex) ? item : closest
|
||||||
|
}, rangedCandidates[0])
|
||||||
|
|
||||||
|
return selected.block.id
|
||||||
|
})
|
||||||
const headingIdsByBlock = computed(() => {
|
const headingIdsByBlock = computed(() => {
|
||||||
const createHeadingId = createHeadingIdFactory()
|
const createHeadingId = createHeadingIdFactory()
|
||||||
const ids = {}
|
const ids = {}
|
||||||
@@ -3259,6 +3329,13 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SiteAdSlot
|
||||||
|
v-if="block.id === inArticleAdAfterBlockId"
|
||||||
|
class="content-markdown-renderer__in-article-ad my-8"
|
||||||
|
:code="normalizedInArticleAdCode"
|
||||||
|
location="post-in-article"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
2
db/migrations/052_site_settings_post_in_article_ad.sql
Normal file
2
db/migrations/052_site_settings_post_in_article_ad.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS ad_post_in_article_code TEXT NOT NULL DEFAULT '';
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-06-05 v1.5.73 — 게시물 인아티클 광고는 충분히 긴 본문 40% 지점에 1회 삽입
|
||||||
|
|
||||||
|
게시물 상세에는 이미 본문 상단·하단 광고 슬롯이 있으므로 인아티클 광고는 너무 자주 노출하지 않는다. 공개 렌더러가 편집 모드가 아닐 때만 광고 코드를 받고, 본문 블록이 충분히 길며 일반 문단 후보가 여유 있게 있을 때 전체 블록 40% 근처 문단 뒤에 한 번 삽입한다. 짧은 글이나 문단 후보가 부족한 글에서는 광고를 생략해 본문 흐름과 상하단 광고 간격을 유지한다.
|
||||||
|
|
||||||
## 2026-06-05 v1.5.72 — 메인 인피드 광고는 클라이언트에서 한 번 무작위 배치
|
## 2026-06-05 v1.5.72 — 메인 인피드 광고는 클라이언트에서 한 번 무작위 배치
|
||||||
|
|
||||||
메인 Latest 목록 사이 광고는 매번 같은 위치보다 자연스럽게 보이도록 무작위 삽입을 허용한다. 다만 SSR 단계에서 무작위 값을 만들면 서버 HTML과 클라이언트 hydration 결과가 달라질 수 있으므로, 브라우저 마운트 이후 게시물 사이 한 지점을 한 번 산출해 삽입한다. 광고는 첫 항목 앞이나 마지막 항목 뒤가 아니라 게시물 사이에만 들어가도록 제한한다.
|
메인 Latest 목록 사이 광고는 매번 같은 위치보다 자연스럽게 보이도록 무작위 삽입을 허용한다. 다만 SSR 단계에서 무작위 값을 만들면 서버 HTML과 클라이언트 hydration 결과가 달라질 수 있으므로, 브라우저 마운트 이후 게시물 사이 한 지점을 한 번 산출해 삽입한다. 광고는 첫 항목 앞이나 마지막 항목 뒤가 아니라 게시물 사이에만 들어가도록 제한한다.
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
||||||
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 본문 상단·하단 광고 슬롯 |
|
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
|
||||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
||||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
|
||||||
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
|
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
|
||||||
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 본문 상단·하단) |
|
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
|
||||||
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
|
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
|
||||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 공개 게시물 인아티클 광고 40% 지점 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
||||||
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
||||||
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
|
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
|
||||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
### Post 페이지
|
### Post 페이지
|
||||||
|
|
||||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||||
- 사이트 설정 Ads의 게시물 본문 상단·하단 광고 코드가 있으면 본문 렌더링 전후에 광고 슬롯을 표시한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
|
- 사이트 설정 Ads의 게시물 본문 상단·하단 광고 코드가 있으면 본문 렌더링 전후에 광고 슬롯을 표시한다. 게시물 인아티클 광고 코드가 있고 본문이 충분히 길면 공개 게시물 본문 전체 블록 40% 근처의 일반 문단 뒤에 한 번 삽입한다. 비어 있거나 짧은 글이면 슬롯 자체를 렌더링하지 않는다.
|
||||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||||
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
||||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
|
|
||||||
### 사이트 광고 슬롯
|
### 사이트 광고 슬롯
|
||||||
|
|
||||||
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 다섯 위치의 HTML 코드를 저장한다.
|
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostInArticleCode`, `adPostBottomCode` 여섯 위치의 HTML 코드를 저장한다.
|
||||||
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
|
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
|
||||||
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
|
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
|
||||||
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
|
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.5.73
|
||||||
|
|
||||||
|
- 사이트 설정 Ads: 게시물 인아티클 광고 슬롯 추가.
|
||||||
|
- 게시물 본문: 충분히 긴 글에서 전체 블록 40% 근처 일반 문단 뒤에 인아티클 광고를 한 번 삽입하도록 추가.
|
||||||
|
- 사이트 설정 저장소: 게시물 인아티클 광고 코드 컬럼과 관리자 입력 검증 추가.
|
||||||
|
|
||||||
## v1.5.72
|
## v1.5.72
|
||||||
|
|
||||||
- 사이트 설정 Ads: 메인 인피드 광고 슬롯 추가.
|
- 사이트 설정 Ads: 메인 인피드 광고 슬롯 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.72",
|
"version": "1.5.73",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.72",
|
"version": "1.5.73",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.72",
|
"version": "1.5.73",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ const adsSnapshot = reactive({
|
|||||||
adHomeInfeedCode: '',
|
adHomeInfeedCode: '',
|
||||||
adSidebarCode: '',
|
adSidebarCode: '',
|
||||||
adPostTopCode: '',
|
adPostTopCode: '',
|
||||||
|
adPostInArticleCode: '',
|
||||||
adPostBottomCode: ''
|
adPostBottomCode: ''
|
||||||
})
|
})
|
||||||
let toastTimer = null
|
let toastTimer = null
|
||||||
@@ -182,6 +183,7 @@ const form = reactive({
|
|||||||
adHomeInfeedCode: settings.value?.adHomeInfeedCode || '',
|
adHomeInfeedCode: settings.value?.adHomeInfeedCode || '',
|
||||||
adSidebarCode: settings.value?.adSidebarCode || '',
|
adSidebarCode: settings.value?.adSidebarCode || '',
|
||||||
adPostTopCode: settings.value?.adPostTopCode || '',
|
adPostTopCode: settings.value?.adPostTopCode || '',
|
||||||
|
adPostInArticleCode: settings.value?.adPostInArticleCode || '',
|
||||||
adPostBottomCode: settings.value?.adPostBottomCode || ''
|
adPostBottomCode: settings.value?.adPostBottomCode || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -276,6 +278,7 @@ const hasAdsChanges = computed(() => editAds.value && (
|
|||||||
|| form.adHomeInfeedCode !== adsSnapshot.adHomeInfeedCode
|
|| form.adHomeInfeedCode !== adsSnapshot.adHomeInfeedCode
|
||||||
|| form.adSidebarCode !== adsSnapshot.adSidebarCode
|
|| form.adSidebarCode !== adsSnapshot.adSidebarCode
|
||||||
|| form.adPostTopCode !== adsSnapshot.adPostTopCode
|
|| form.adPostTopCode !== adsSnapshot.adPostTopCode
|
||||||
|
|| form.adPostInArticleCode !== adsSnapshot.adPostInArticleCode
|
||||||
|| form.adPostBottomCode !== adsSnapshot.adPostBottomCode
|
|| form.adPostBottomCode !== adsSnapshot.adPostBottomCode
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -1134,6 +1137,7 @@ const buildSiteSettingsPayload = () => ({
|
|||||||
adHomeInfeedCode: form.adHomeInfeedCode || '',
|
adHomeInfeedCode: form.adHomeInfeedCode || '',
|
||||||
adSidebarCode: form.adSidebarCode || '',
|
adSidebarCode: form.adSidebarCode || '',
|
||||||
adPostTopCode: form.adPostTopCode || '',
|
adPostTopCode: form.adPostTopCode || '',
|
||||||
|
adPostInArticleCode: form.adPostInArticleCode || '',
|
||||||
adPostBottomCode: form.adPostBottomCode || ''
|
adPostBottomCode: form.adPostBottomCode || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1727,6 +1731,7 @@ const beginEditAds = () => {
|
|||||||
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
|
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
|
||||||
adsSnapshot.adSidebarCode = form.adSidebarCode
|
adsSnapshot.adSidebarCode = form.adSidebarCode
|
||||||
adsSnapshot.adPostTopCode = form.adPostTopCode
|
adsSnapshot.adPostTopCode = form.adPostTopCode
|
||||||
|
adsSnapshot.adPostInArticleCode = form.adPostInArticleCode
|
||||||
adsSnapshot.adPostBottomCode = form.adPostBottomCode
|
adsSnapshot.adPostBottomCode = form.adPostBottomCode
|
||||||
editAds.value = true
|
editAds.value = true
|
||||||
}
|
}
|
||||||
@@ -1740,6 +1745,7 @@ const cancelEditAds = () => {
|
|||||||
form.adHomeInfeedCode = adsSnapshot.adHomeInfeedCode
|
form.adHomeInfeedCode = adsSnapshot.adHomeInfeedCode
|
||||||
form.adSidebarCode = adsSnapshot.adSidebarCode
|
form.adSidebarCode = adsSnapshot.adSidebarCode
|
||||||
form.adPostTopCode = adsSnapshot.adPostTopCode
|
form.adPostTopCode = adsSnapshot.adPostTopCode
|
||||||
|
form.adPostInArticleCode = adsSnapshot.adPostInArticleCode
|
||||||
form.adPostBottomCode = adsSnapshot.adPostBottomCode
|
form.adPostBottomCode = adsSnapshot.adPostBottomCode
|
||||||
editAds.value = false
|
editAds.value = false
|
||||||
}
|
}
|
||||||
@@ -1763,6 +1769,7 @@ const saveAdsSection = async () => {
|
|||||||
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
|
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
|
||||||
adsSnapshot.adSidebarCode = form.adSidebarCode
|
adsSnapshot.adSidebarCode = form.adSidebarCode
|
||||||
adsSnapshot.adPostTopCode = form.adPostTopCode
|
adsSnapshot.adPostTopCode = form.adPostTopCode
|
||||||
|
adsSnapshot.adPostInArticleCode = form.adPostInArticleCode
|
||||||
adsSnapshot.adPostBottomCode = form.adPostBottomCode
|
adsSnapshot.adPostBottomCode = form.adPostBottomCode
|
||||||
editAds.value = false
|
editAds.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,11 @@ useHead(() => ({
|
|||||||
location="post-top"
|
location="post-top"
|
||||||
/>
|
/>
|
||||||
<ContentRenderer>
|
<ContentRenderer>
|
||||||
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
<ContentMarkdownRenderer
|
||||||
|
class="post-detail__content"
|
||||||
|
:content="post.content"
|
||||||
|
:in-article-ad-code="siteSettings?.adPostInArticleCode"
|
||||||
|
/>
|
||||||
</ContentRenderer>
|
</ContentRenderer>
|
||||||
<SiteAdSlot
|
<SiteAdSlot
|
||||||
class="post-detail__ad-slot post-detail__ad-slot--bottom mt-8"
|
class="post-detail__ad-slot post-detail__ad-slot--bottom mt-8"
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ const mapSiteSettingsRow = (row) => ({
|
|||||||
adHomeInfeedCode: row.ad_home_infeed_code || '',
|
adHomeInfeedCode: row.ad_home_infeed_code || '',
|
||||||
adSidebarCode: row.ad_sidebar_code || '',
|
adSidebarCode: row.ad_sidebar_code || '',
|
||||||
adPostTopCode: row.ad_post_top_code || '',
|
adPostTopCode: row.ad_post_top_code || '',
|
||||||
|
adPostInArticleCode: row.ad_post_in_article_code || '',
|
||||||
adPostBottomCode: row.ad_post_bottom_code || '',
|
adPostBottomCode: row.ad_post_bottom_code || '',
|
||||||
updatedAt: row.updated_at.toISOString()
|
updatedAt: row.updated_at.toISOString()
|
||||||
})
|
})
|
||||||
@@ -903,6 +904,7 @@ export const updateSiteSettings = async (input) => {
|
|||||||
ad_home_infeed_code,
|
ad_home_infeed_code,
|
||||||
ad_sidebar_code,
|
ad_sidebar_code,
|
||||||
ad_post_top_code,
|
ad_post_top_code,
|
||||||
|
ad_post_in_article_code,
|
||||||
ad_post_bottom_code,
|
ad_post_bottom_code,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
@@ -935,6 +937,7 @@ export const updateSiteSettings = async (input) => {
|
|||||||
${input.adHomeInfeedCode || ''},
|
${input.adHomeInfeedCode || ''},
|
||||||
${input.adSidebarCode || ''},
|
${input.adSidebarCode || ''},
|
||||||
${input.adPostTopCode || ''},
|
${input.adPostTopCode || ''},
|
||||||
|
${input.adPostInArticleCode || ''},
|
||||||
${input.adPostBottomCode || ''},
|
${input.adPostBottomCode || ''},
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
@@ -967,6 +970,7 @@ export const updateSiteSettings = async (input) => {
|
|||||||
ad_home_infeed_code = EXCLUDED.ad_home_infeed_code,
|
ad_home_infeed_code = EXCLUDED.ad_home_infeed_code,
|
||||||
ad_sidebar_code = EXCLUDED.ad_sidebar_code,
|
ad_sidebar_code = EXCLUDED.ad_sidebar_code,
|
||||||
ad_post_top_code = EXCLUDED.ad_post_top_code,
|
ad_post_top_code = EXCLUDED.ad_post_top_code,
|
||||||
|
ad_post_in_article_code = EXCLUDED.ad_post_in_article_code,
|
||||||
ad_post_bottom_code = EXCLUDED.ad_post_bottom_code,
|
ad_post_bottom_code = EXCLUDED.ad_post_bottom_code,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const adminSiteSettingsInputSchema = z.object({
|
|||||||
adHomeInfeedCode: z.string().max(50000).optional().default(''),
|
adHomeInfeedCode: z.string().max(50000).optional().default(''),
|
||||||
adSidebarCode: z.string().max(50000).optional().default(''),
|
adSidebarCode: z.string().max(50000).optional().default(''),
|
||||||
adPostTopCode: z.string().max(50000).optional().default(''),
|
adPostTopCode: z.string().max(50000).optional().default(''),
|
||||||
|
adPostInArticleCode: z.string().max(50000).optional().default(''),
|
||||||
adPostBottomCode: z.string().max(50000).optional().default('')
|
adPostBottomCode: z.string().max(50000).optional().default('')
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (!isValidAnnouncementBackgroundColor(data.announcementBackgroundColor)) {
|
if (!isValidAnnouncementBackgroundColor(data.announcementBackgroundColor)) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const getDefaultSiteSettings = () => {
|
|||||||
adHomeInfeedCode: '',
|
adHomeInfeedCode: '',
|
||||||
adSidebarCode: '',
|
adSidebarCode: '',
|
||||||
adPostTopCode: '',
|
adPostTopCode: '',
|
||||||
|
adPostInArticleCode: '',
|
||||||
adPostBottomCode: '',
|
adPostBottomCode: '',
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user