From 5c93643949be92d4617018954bd66e990e55e0a5 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 5 Jun 2026 16:02:50 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=9D=B8=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EA=B4=91=EA=B3=A0=20=EC=8A=AC=EB=A1=AF=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminAdsSettingsCard.vue | 6 + .../051_site_settings_home_infeed_ad.sql | 2 + docs/history.md | 4 + docs/map.md | 4 +- docs/spec.md | 4 +- docs/update.md | 6 + package-lock.json | 4 +- package.json | 2 +- pages/admin/settings/index.vue | 7 + pages/index.vue | 191 +++++++++++------- server/repositories/content-repository.js | 4 + server/utils/admin-site-settings-input.js | 1 + server/utils/site-settings.js | 1 + 13 files changed, 152 insertions(+), 84 deletions(-) create mode 100644 db/migrations/051_site_settings_home_infeed_ad.sql diff --git a/components/admin/AdminAdsSettingsCard.vue b/components/admin/AdminAdsSettingsCard.vue index bd979c4..965f87b 100644 --- a/components/admin/AdminAdsSettingsCard.vue +++ b/components/admin/AdminAdsSettingsCard.vue @@ -6,6 +6,12 @@ const adSlots = [ description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.', placeholder: '' }, + { + key: 'adHomeInfeedCode', + label: '메인 인피드', + description: '메인 화면 Latest 게시물 목록 사이 한 곳에 무작위로 표시됩니다.', + placeholder: '' + }, { key: 'adSidebarCode', label: '오른쪽 사이드', diff --git a/db/migrations/051_site_settings_home_infeed_ad.sql b/db/migrations/051_site_settings_home_infeed_ad.sql new file mode 100644 index 0000000..2d04896 --- /dev/null +++ b/db/migrations/051_site_settings_home_infeed_ad.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + ADD COLUMN IF NOT EXISTS ad_home_infeed_code TEXT NOT NULL DEFAULT ''; diff --git a/docs/history.md b/docs/history.md index 28ba95a..c3c2988 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-05 v1.5.72 — 메인 인피드 광고는 클라이언트에서 한 번 무작위 배치 + +메인 Latest 목록 사이 광고는 매번 같은 위치보다 자연스럽게 보이도록 무작위 삽입을 허용한다. 다만 SSR 단계에서 무작위 값을 만들면 서버 HTML과 클라이언트 hydration 결과가 달라질 수 있으므로, 브라우저 마운트 이후 게시물 사이 한 지점을 한 번 산출해 삽입한다. 광고는 첫 항목 앞이나 마지막 항목 뒤가 아니라 게시물 사이에만 들어가도록 제한한다. + ## 2026-06-05 v1.5.71 — 화면 위치 광고 슬롯은 사이트 코드와 분리 관리 사이트 검증·공통 스크립트용 사이트 코드 카드와, 실제 화면 위치에 삽입되는 광고 단위를 분리했다. 이유는 ads.txt/head/footer는 전역 설정이고, 메인 피드·사이드바·게시물 본문 상단·하단 광고는 위치별 표시 여부와 레이아웃 책임이 다르기 때문이다. 광고 HTML은 관리자 신뢰 콘텐츠로 저장하되, 빈 값은 DOM을 만들지 않고 클라이언트에서만 삽입해 AdSense 스크립트 중복 실행 가능성을 줄인다. diff --git a/docs/map.md b/docs/map.md index b97baa3..82c842e 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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/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/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·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 | diff --git a/docs/spec.md b/docs/spec.md index 544ac40..cf5aeba 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -64,7 +64,7 @@ - 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다. - `compact`는 썸네일을 포함한 짧은 행 형태, `list`는 텍스트 중심 목록 형태, `cards`는 카드 그리드 형태로 표시한다. - Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다. -- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다. +- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다. ### Post 페이지 @@ -119,7 +119,7 @@ ### 사이트 광고 슬롯 -- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 네 위치의 HTML 코드를 저장한다. +- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 다섯 위치의 HTML 코드를 저장한다. - 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다. - 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다. - 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다. diff --git a/docs/update.md b/docs/update.md index 5674290..c27bf06 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.5.72 + +- 사이트 설정 Ads: 메인 인피드 광고 슬롯 추가. +- 메인 화면: Latest 게시물 목록 사이 한 곳에 인피드 광고를 무작위 삽입하도록 추가. +- 사이트 설정 저장소: 메인 인피드 광고 코드 컬럼과 관리자 입력 검증 추가. + ## v1.5.71 - 사이트 설정: 위치별 광고 코드를 관리하는 Ads 카드 추가. diff --git a/package-lock.json b/package-lock.json index 6c0cea7..a139008 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.71", + "version": "1.5.72", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.71", + "version": "1.5.72", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 2ea22cd..193533c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.71", + "version": "1.5.72", "private": true, "type": "module", "imports": { diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index 8a8297b..2311adc 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -137,6 +137,7 @@ const siteCodeSnapshot = reactive({ /** 편집 시작 시점의 광고 슬롯(취소 시 복원용) */ const adsSnapshot = reactive({ adHomeFeedCode: '', + adHomeInfeedCode: '', adSidebarCode: '', adPostTopCode: '', adPostBottomCode: '' @@ -178,6 +179,7 @@ const form = reactive({ customHeadCode: settings.value?.customHeadCode || '', customFooterCode: settings.value?.customFooterCode || '', adHomeFeedCode: settings.value?.adHomeFeedCode || '', + adHomeInfeedCode: settings.value?.adHomeInfeedCode || '', adSidebarCode: settings.value?.adSidebarCode || '', adPostTopCode: settings.value?.adPostTopCode || '', adPostBottomCode: settings.value?.adPostBottomCode || '' @@ -271,6 +273,7 @@ const hasSiteCodeChanges = computed(() => editSiteCode.value && ( */ const hasAdsChanges = computed(() => editAds.value && ( form.adHomeFeedCode !== adsSnapshot.adHomeFeedCode + || form.adHomeInfeedCode !== adsSnapshot.adHomeInfeedCode || form.adSidebarCode !== adsSnapshot.adSidebarCode || form.adPostTopCode !== adsSnapshot.adPostTopCode || form.adPostBottomCode !== adsSnapshot.adPostBottomCode @@ -1128,6 +1131,7 @@ const buildSiteSettingsPayload = () => ({ customHeadCode: form.customHeadCode || '', customFooterCode: form.customFooterCode || '', adHomeFeedCode: form.adHomeFeedCode || '', + adHomeInfeedCode: form.adHomeInfeedCode || '', adSidebarCode: form.adSidebarCode || '', adPostTopCode: form.adPostTopCode || '', adPostBottomCode: form.adPostBottomCode || '' @@ -1720,6 +1724,7 @@ const saveSiteCodeSection = async () => { */ const beginEditAds = () => { adsSnapshot.adHomeFeedCode = form.adHomeFeedCode + adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode adsSnapshot.adSidebarCode = form.adSidebarCode adsSnapshot.adPostTopCode = form.adPostTopCode adsSnapshot.adPostBottomCode = form.adPostBottomCode @@ -1732,6 +1737,7 @@ const beginEditAds = () => { */ const cancelEditAds = () => { form.adHomeFeedCode = adsSnapshot.adHomeFeedCode + form.adHomeInfeedCode = adsSnapshot.adHomeInfeedCode form.adSidebarCode = adsSnapshot.adSidebarCode form.adPostTopCode = adsSnapshot.adPostTopCode form.adPostBottomCode = adsSnapshot.adPostBottomCode @@ -1754,6 +1760,7 @@ const saveAdsSection = async () => { if (ok) { adsSnapshot.adHomeFeedCode = form.adHomeFeedCode + adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode adsSnapshot.adSidebarCode = form.adSidebarCode adsSnapshot.adPostTopCode = form.adPostTopCode adsSnapshot.adPostBottomCode = form.adPostBottomCode diff --git a/pages/index.vue b/pages/index.vue index 5717861..f06e457 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -15,6 +15,7 @@ const postFeedStyleStorageKey = 'POST_FEED_STYLE' const postFeedStyleOpen = ref(false) const postFeedStyle = ref('compact') +const homeInfeedAdInsertIndex = ref(-1) /** @typedef {'list' | 'compact' | 'cards'} PostFeedStyle */ @@ -165,6 +166,7 @@ const mapLatestPost = (post) => { const featuredPosts = computed(() => posts.value.filter((post) => post.isFeatured).slice(0, 6)) const latestPosts = computed(() => posts.value.map(mapLatestPost)) +const hasHomeInfeedAd = computed(() => Boolean(String(siteSettings.value?.adHomeInfeedCode || '').trim())) const featuredTrackRef = ref(null) /** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */ @@ -236,6 +238,7 @@ onMounted(() => { const storedStyle = localStorage.getItem(postFeedStyleStorageKey) postFeedStyle.value = normalizePostFeedStyle(storedStyle) + randomizeHomeInfeedAdInsertIndex() document.addEventListener('pointerdown', onDocumentPointerDown) }) @@ -277,6 +280,29 @@ const scrollFeatured = (direction) => { behavior: 'smooth' }) } + +/** + * 메인 인피드 광고 삽입 위치를 현재 브라우저 렌더에서 한 번 정한다. + * @returns {void} + */ +const randomizeHomeInfeedAdInsertIndex = () => { + if (!hasHomeInfeedAd.value || latestPosts.value.length < 2) { + homeInfeedAdInsertIndex.value = -1 + return + } + + const minIndex = 0 + const maxIndex = latestPosts.value.length - 2 + homeInfeedAdInsertIndex.value = minIndex + Math.floor(Math.random() * (maxIndex - minIndex + 1)) +} + +watch([latestPosts, hasHomeInfeedAd], () => { + if (!import.meta.client) { + return + } + + randomizeHomeInfeedAdInsertIndex() +}) diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js index fdd4575..88d9a6a 100644 --- a/server/repositories/content-repository.js +++ b/server/repositories/content-repository.js @@ -125,6 +125,7 @@ const mapSiteSettingsRow = (row) => ({ customHeadCode: row.custom_head_code || '', customFooterCode: row.custom_footer_code || '', adHomeFeedCode: row.ad_home_feed_code || '', + adHomeInfeedCode: row.ad_home_infeed_code || '', adSidebarCode: row.ad_sidebar_code || '', adPostTopCode: row.ad_post_top_code || '', adPostBottomCode: row.ad_post_bottom_code || '', @@ -899,6 +900,7 @@ export const updateSiteSettings = async (input) => { custom_head_code, custom_footer_code, ad_home_feed_code, + ad_home_infeed_code, ad_sidebar_code, ad_post_top_code, ad_post_bottom_code, @@ -930,6 +932,7 @@ export const updateSiteSettings = async (input) => { ${input.customHeadCode || ''}, ${input.customFooterCode || ''}, ${input.adHomeFeedCode || ''}, + ${input.adHomeInfeedCode || ''}, ${input.adSidebarCode || ''}, ${input.adPostTopCode || ''}, ${input.adPostBottomCode || ''}, @@ -961,6 +964,7 @@ export const updateSiteSettings = async (input) => { custom_head_code = EXCLUDED.custom_head_code, custom_footer_code = EXCLUDED.custom_footer_code, 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_top_code = EXCLUDED.ad_post_top_code, ad_post_bottom_code = EXCLUDED.ad_post_bottom_code, diff --git a/server/utils/admin-site-settings-input.js b/server/utils/admin-site-settings-input.js index a272610..b892ee4 100644 --- a/server/utils/admin-site-settings-input.js +++ b/server/utils/admin-site-settings-input.js @@ -47,6 +47,7 @@ export const adminSiteSettingsInputSchema = z.object({ customHeadCode: z.string().max(50000).optional().default(''), customFooterCode: z.string().max(50000).optional().default(''), adHomeFeedCode: z.string().max(50000).optional().default(''), + adHomeInfeedCode: z.string().max(50000).optional().default(''), adSidebarCode: z.string().max(50000).optional().default(''), adPostTopCode: z.string().max(50000).optional().default(''), adPostBottomCode: z.string().max(50000).optional().default('') diff --git a/server/utils/site-settings.js b/server/utils/site-settings.js index 1073ae0..13235d8 100644 --- a/server/utils/site-settings.js +++ b/server/utils/site-settings.js @@ -38,6 +38,7 @@ export const getDefaultSiteSettings = () => { customHeadCode: '', customFooterCode: '', adHomeFeedCode: '', + adHomeInfeedCode: '', adSidebarCode: '', adPostTopCode: '', adPostBottomCode: '',