게시물 광고 배치 조정

This commit is contained in:
2026-06-05 16:32:29 +09:00
parent cc9e5949fa
commit 629ef8c4c6
16 changed files with 129 additions and 31 deletions

View File

@@ -15,7 +15,13 @@ const adSlots = [
{
key: 'adSidebarCode',
label: '오른쪽 사이드',
description: '오른쪽 사이드바 하단 영역에 표시됩니다.',
description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostSidebarCode',
label: '게시물 왼쪽 사이드',
description: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
@@ -27,7 +33,7 @@ const adSlots = [
{
key: 'adPostInArticleCode',
label: '게시물 인아티클',
description: '게시물 본문이 충분히 길 때 전체 블록 40% 근처 문단 뒤에 한 번 표시됩니다.',
description: '게시물 본문이 충분히 길 때 본문 중간 문단 뒤에 표시되며, 긴 글은 최대 두 번 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{

View File

@@ -92,6 +92,12 @@ const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
/** @type {string} 본문 리스트 마커 색상 */
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
/** @type {number} 인아티클 광고 최소 본문 글자 수 */
const IN_ARTICLE_AD_MIN_TEXT_LENGTH = 2000
/** @type {number} 인아티클 광고 2회 표시 기준 본문 글자 수 */
const IN_ARTICLE_AD_SECOND_TEXT_LENGTH = 6000
/** @type {number} 인아티클 광고 사이 최소 블록 간격 */
const IN_ARTICLE_AD_MIN_BLOCK_SPACING = 8
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
@@ -784,19 +790,38 @@ const isInArticleAdCandidateBlock = (block) => {
}
/**
* 본문 인아티클 광고를 삽입할 블록 ID를 반환한다.
* @returns {string} 삽입 기준 블록 ID
* 본문 인아티클 광고 목표 위치 비율 목록을 반환한다.
* @param {number} plainTextLength - 본문 글자 수
* @returns {number[]} 광고 목표 위치 비율
*/
const inArticleAdAfterBlockId = computed(() => {
const getInArticleAdTargetRatios = (plainTextLength) => {
if (plainTextLength >= IN_ARTICLE_AD_SECOND_TEXT_LENGTH) {
return [0.35, 0.7]
}
if (plainTextLength >= IN_ARTICLE_AD_MIN_TEXT_LENGTH) {
return [0.4]
}
return []
}
/**
* 본문 인아티클 광고를 삽입할 블록 ID 목록을 반환한다.
* @returns {string[]} 삽입 기준 블록 ID 목록
*/
const inArticleAdAfterBlockIds = computed(() => {
if (props.interactive || !normalizedInArticleAdCode.value) {
return ''
return []
}
const currentBlocks = blocks.value
const contentBlocks = currentBlocks.filter((block) => !['spacer', 'divider'].includes(block.type))
const plainTextLength = contentBlocks.reduce((sum, block) => sum + getBlockPlainTextLength(block), 0)
const targetRatios = getInArticleAdTargetRatios(plainTextLength)
if (contentBlocks.length < 10) {
return ''
if (contentBlocks.length < 10 || !targetRatios.length) {
return []
}
const candidates = currentBlocks
@@ -804,29 +829,48 @@ const inArticleAdAfterBlockId = computed(() => {
.filter((item) => isInArticleAdCandidateBlock(item.block))
if (candidates.length < 6) {
return ''
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))
const maxIndex = Math.min(currentBlocks.length - 4, Math.floor(currentBlocks.length * 0.8))
if (maxIndex < minIndex) {
return ''
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 ''
return []
}
const selected = rangedCandidates.reduce((closest, item) => {
return Math.abs(item.index - targetIndex) < Math.abs(closest.index - targetIndex) ? item : closest
}, rangedCandidates[0])
const selectedItems = []
return selected.block.id
for (const ratio of targetRatios) {
const targetIndex = Math.floor((currentBlocks.length - 1) * ratio)
const availableCandidates = rangedCandidates.filter((item) => {
return selectedItems.every((selected) => Math.abs(selected.index - item.index) >= IN_ARTICLE_AD_MIN_BLOCK_SPACING)
})
if (!availableCandidates.length) {
continue
}
const selected = availableCandidates.reduce((closest, item) => {
return Math.abs(item.index - targetIndex) < Math.abs(closest.index - targetIndex) ? item : closest
}, availableCandidates[0])
selectedItems.push(selected)
}
return selectedItems
.sort((a, b) => a.index - b.index)
.map((item) => item.block.id)
})
const inArticleAdAfterBlockIdSet = computed(() => new Set(inArticleAdAfterBlockIds.value))
const headingIdsByBlock = computed(() => {
const createHeadingId = createHeadingIdFactory()
const ids = {}
@@ -3331,7 +3375,7 @@ onBeforeUnmount(() => {
</p>
<SiteAdSlot
v-if="block.id === inArticleAdAfterBlockId"
v-if="inArticleAdAfterBlockIdSet.has(block.id)"
class="content-markdown-renderer__in-article-ad my-8"
:code="normalizedInArticleAdCode"
location="post-in-article"

View File

@@ -7,6 +7,13 @@ defineProps({
})
const { isDarkMode, toggleTheme } = useThemeMode()
const route = useRoute()
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
adPostSidebarCode: ''
})
})
const { data: tags } = await useFetch('/api/tags', {
default: () => []
@@ -22,6 +29,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
/** 저자 영역 공개 여부 */
const showAuthorSection = false
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const STORAGE_KEY = 'sori-primary-nav-expanded'
@@ -193,6 +201,13 @@ onMounted(() => {
</div>
</div>
</div>
<SiteAdSlot
v-if="isPostDetailRoute"
class="left-sidebar__post-ad-slot site-sidebar-section px-5 py-5 pr-3 max-lg:hidden xl:pl-0"
:code="siteSettings?.adPostSidebarCode"
location="post-sidebar-left"
/>
</div>
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">

View File

@@ -39,6 +39,7 @@ const recommendedSites = computed(() => {
return list.filter((x) => x?.isVisible !== false)
})
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const sidebarAdCode = computed(() => isPostDetailRoute.value ? '' : siteSettings.value?.adSidebarCode)
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
@@ -489,7 +490,7 @@ watch([postTocItems, () => route.fullPath], async () => {
<SiteAdSlot
class="right-sidebar__ad-slot site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0"
:code="siteSettings?.adSidebarCode"
:code="sidebarAdCode"
location="sidebar"
/>
</div>

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS ad_post_sidebar_code TEXT NOT NULL DEFAULT '';

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.55에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.74에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,13 @@
## 로컬 개발
### v1.5.74 참고
- DB 마이그레이션 `053_site_settings_post_sidebar_ad.sql` 적용 필요.
- 사이트 설정 Ads에서 게시물 왼쪽 사이드 광고 코드를 저장한 뒤 게시물 상세 데스크톱 왼쪽 사이드바 하단에만 표시되는지 확인한다.
- 게시물 상세 오른쪽 사이드바에서는 공통 오른쪽 사이드 광고가 표시되지 않는지 확인한다.
- 긴 게시물에서 인아티클 광고가 본문 길이에 따라 0~2회로 제한되는지 확인한다.
### 필수 조건
- Node.js 22 LTS 권장

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-05 v1.5.74 — 게시물 상세 광고는 목차와 분리한다
게시물 상세 오른쪽 사이드바는 본문 목차가 핵심 기능이므로 공통 사이드 광고와 함께 두면 폭과 클릭 맥락이 어색해질 수 있다. 따라서 공통 오른쪽 사이드 광고는 게시물 상세가 아닌 화면에만 유지하고, 게시물 상세에는 별도 왼쪽 사이드 광고 슬롯을 둔다. 인아티클 광고는 상단·하단 광고와 함께 쓰이므로 짧은 글에서는 생략하고, 충분히 긴 글에서만 본문 흐름을 해치지 않도록 1회 또는 최대 2회로 제한한다.
## 2026-06-05 v1.5.73 — 게시물 인아티클 광고는 충분히 긴 본문 40% 지점에 1회 삽입
게시물 상세에는 이미 본문 상단·하단 광고 슬롯이 있으므로 인아티클 광고는 너무 자주 노출하지 않는다. 공개 렌더러가 편집 모드가 아닐 때만 광고 코드를 받고, 본문 블록이 충분히 길며 일반 문단 후보가 여유 있게 있을 때 전체 블록 40% 근처 문단 뒤에 한 번 삽입한다. 짧은 글이나 문단 후보가 부족한 글에서는 광고를 생략해 본문 흐름과 상하단 광고 간격을 유지한다.

View File

@@ -74,10 +74,10 @@
| components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·hex 배경색·텍스트 정렬·닫기) |
| 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` 호버 |
| 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/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/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/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
@@ -91,7 +91,7 @@
|------|-----------|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 왼쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
@@ -114,7 +114,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 공개 게시물 인아티클 광고 40% 지점 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |

View File

@@ -69,7 +69,7 @@
### Post 페이지
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
- 사이트 설정 Ads의 게시물 본문 상단·하단 광고 코드가 있으면 본문 렌더링 전후에 광고 슬롯을 표시한다. 게시물 인아티클 광고 코드가 있고 본문이 충분히 길면 공개 게시물 본문 전체 블록 40% 근처 일반 문단 뒤에 한 번 삽입한다. 비어 있거나 짧은 글이면 슬롯 자체를 렌더링하지 않는다.
- 사이트 설정 Ads의 게시물 본문 상단·하단 광고 코드가 있으면 본문 렌더링 전후에 광고 슬롯을 표시한다. 게시물 인아티클 광고 코드가 있으면 공백 제외 본문 길이 2,000자 미만에서는 표시하지 않고, 2,000자 이상은 전체 블록 40% 근처 일반 문단 뒤에 한 번, 6,000자 이상은 35%·70% 근처 일반 문단 뒤에 최대 두 번 표시한다. 광고 사이에는 최소 8개 블록 간격을 둔다.
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
@@ -81,7 +81,7 @@
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다. 게시물 상세에서는 오른쪽 사이드바의 공통 광고를 숨기고, 게시물 왼쪽 사이드 광고 코드가 있을 때 데스크톱 왼쪽 사이드바 하단에 광고 슬롯을 표시한다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
@@ -119,7 +119,7 @@
### 사이트 광고 슬롯
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostInArticleCode`, `adPostBottomCode` 여섯 위치의 HTML 코드를 저장한다.
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostSidebarCode`, `adPostTopCode`, `adPostInArticleCode`, `adPostBottomCode` 일곱 위치의 HTML 코드를 저장한다.
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.5.74
- 사이트 설정 Ads: 게시물 왼쪽 사이드 광고 슬롯 추가.
- 공개 게시물 화면: 오른쪽 TOC 사이드바에서는 공통 사이드 광고를 숨기고, 데스크톱 왼쪽 사이드바 하단에 게시물 전용 광고를 표시하도록 수정.
- 게시물 본문: 인아티클 광고를 글 길이에 따라 짧은 글 0회, 중간 글 1회, 긴 글 최대 2회까지 삽입하도록 조정.
## v1.5.73
- 사이트 설정 Ads: 게시물 인아티클 광고 슬롯 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.73",
"version": "1.5.74",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.73",
"version": "1.5.74",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -139,6 +139,7 @@ const adsSnapshot = reactive({
adHomeFeedCode: '',
adHomeInfeedCode: '',
adSidebarCode: '',
adPostSidebarCode: '',
adPostTopCode: '',
adPostInArticleCode: '',
adPostBottomCode: ''
@@ -182,6 +183,7 @@ const form = reactive({
adHomeFeedCode: settings.value?.adHomeFeedCode || '',
adHomeInfeedCode: settings.value?.adHomeInfeedCode || '',
adSidebarCode: settings.value?.adSidebarCode || '',
adPostSidebarCode: settings.value?.adPostSidebarCode || '',
adPostTopCode: settings.value?.adPostTopCode || '',
adPostInArticleCode: settings.value?.adPostInArticleCode || '',
adPostBottomCode: settings.value?.adPostBottomCode || ''
@@ -277,6 +279,7 @@ const hasAdsChanges = computed(() => editAds.value && (
form.adHomeFeedCode !== adsSnapshot.adHomeFeedCode
|| form.adHomeInfeedCode !== adsSnapshot.adHomeInfeedCode
|| form.adSidebarCode !== adsSnapshot.adSidebarCode
|| form.adPostSidebarCode !== adsSnapshot.adPostSidebarCode
|| form.adPostTopCode !== adsSnapshot.adPostTopCode
|| form.adPostInArticleCode !== adsSnapshot.adPostInArticleCode
|| form.adPostBottomCode !== adsSnapshot.adPostBottomCode
@@ -1136,6 +1139,7 @@ const buildSiteSettingsPayload = () => ({
adHomeFeedCode: form.adHomeFeedCode || '',
adHomeInfeedCode: form.adHomeInfeedCode || '',
adSidebarCode: form.adSidebarCode || '',
adPostSidebarCode: form.adPostSidebarCode || '',
adPostTopCode: form.adPostTopCode || '',
adPostInArticleCode: form.adPostInArticleCode || '',
adPostBottomCode: form.adPostBottomCode || ''
@@ -1730,6 +1734,7 @@ const beginEditAds = () => {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostSidebarCode = form.adPostSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostInArticleCode = form.adPostInArticleCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode
@@ -1744,6 +1749,7 @@ const cancelEditAds = () => {
form.adHomeFeedCode = adsSnapshot.adHomeFeedCode
form.adHomeInfeedCode = adsSnapshot.adHomeInfeedCode
form.adSidebarCode = adsSnapshot.adSidebarCode
form.adPostSidebarCode = adsSnapshot.adPostSidebarCode
form.adPostTopCode = adsSnapshot.adPostTopCode
form.adPostInArticleCode = adsSnapshot.adPostInArticleCode
form.adPostBottomCode = adsSnapshot.adPostBottomCode
@@ -1768,6 +1774,7 @@ const saveAdsSection = async () => {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostSidebarCode = form.adPostSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostInArticleCode = form.adPostInArticleCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode

View File

@@ -127,6 +127,7 @@ const mapSiteSettingsRow = (row) => ({
adHomeFeedCode: row.ad_home_feed_code || '',
adHomeInfeedCode: row.ad_home_infeed_code || '',
adSidebarCode: row.ad_sidebar_code || '',
adPostSidebarCode: row.ad_post_sidebar_code || '',
adPostTopCode: row.ad_post_top_code || '',
adPostInArticleCode: row.ad_post_in_article_code || '',
adPostBottomCode: row.ad_post_bottom_code || '',
@@ -903,6 +904,7 @@ export const updateSiteSettings = async (input) => {
ad_home_feed_code,
ad_home_infeed_code,
ad_sidebar_code,
ad_post_sidebar_code,
ad_post_top_code,
ad_post_in_article_code,
ad_post_bottom_code,
@@ -936,6 +938,7 @@ export const updateSiteSettings = async (input) => {
${input.adHomeFeedCode || ''},
${input.adHomeInfeedCode || ''},
${input.adSidebarCode || ''},
${input.adPostSidebarCode || ''},
${input.adPostTopCode || ''},
${input.adPostInArticleCode || ''},
${input.adPostBottomCode || ''},
@@ -969,6 +972,7 @@ export const updateSiteSettings = async (input) => {
ad_home_feed_code = EXCLUDED.ad_home_feed_code,
ad_home_infeed_code = EXCLUDED.ad_home_infeed_code,
ad_sidebar_code = EXCLUDED.ad_sidebar_code,
ad_post_sidebar_code = EXCLUDED.ad_post_sidebar_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,

View File

@@ -49,6 +49,7 @@ export const adminSiteSettingsInputSchema = z.object({
adHomeFeedCode: z.string().max(50000).optional().default(''),
adHomeInfeedCode: z.string().max(50000).optional().default(''),
adSidebarCode: z.string().max(50000).optional().default(''),
adPostSidebarCode: 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('')

View File

@@ -40,6 +40,7 @@ export const getDefaultSiteSettings = () => {
adHomeFeedCode: '',
adHomeInfeedCode: '',
adSidebarCode: '',
adPostSidebarCode: '',
adPostTopCode: '',
adPostInArticleCode: '',
adPostBottomCode: '',