diff --git a/components/admin/AdminAdsSettingsCard.vue b/components/admin/AdminAdsSettingsCard.vue index 0f961d6..b98263e 100644 --- a/components/admin/AdminAdsSettingsCard.vue +++ b/components/admin/AdminAdsSettingsCard.vue @@ -15,7 +15,13 @@ const adSlots = [ { key: 'adSidebarCode', label: '오른쪽 사이드', - description: '오른쪽 사이드바 하단 영역에 표시됩니다.', + description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.', + placeholder: '' + }, + { + key: 'adPostSidebarCode', + label: '게시물 왼쪽 사이드', + description: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.', placeholder: '' }, { @@ -27,7 +33,7 @@ const adSlots = [ { key: 'adPostInArticleCode', label: '게시물 인아티클', - description: '게시물 본문이 충분히 길 때 전체 블록 40% 근처 문단 뒤에 한 번 표시됩니다.', + description: '게시물 본문이 충분히 길 때 본문 중간 문단 뒤에 표시되며, 긴 글은 최대 두 번 표시됩니다.', placeholder: '' }, { diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 7b511ac..4436bb4 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -92,6 +92,12 @@ const BLANK_PARAGRAPH_MARKER = '' 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(() => {

({ + 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(() => { + +