메인 인피드 광고 슬롯 추가

This commit is contained in:
2026-06-05 16:02:50 +09:00
parent 9a4820e69c
commit 5c93643949
13 changed files with 152 additions and 84 deletions

View File

@@ -6,6 +6,12 @@ const adSlots = [
description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.', description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>' placeholder: '<ins class="adsbygoogle" ...></ins>'
}, },
{
key: 'adHomeInfeedCode',
label: '메인 인피드',
description: '메인 화면 Latest 게시물 목록 사이 한 곳에 무작위로 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{ {
key: 'adSidebarCode', key: 'adSidebarCode',
label: '오른쪽 사이드', label: '오른쪽 사이드',

View File

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

View File

@@ -1,5 +1,9 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-05 v1.5.72 — 메인 인피드 광고는 클라이언트에서 한 번 무작위 배치
메인 Latest 목록 사이 광고는 매번 같은 위치보다 자연스럽게 보이도록 무작위 삽입을 허용한다. 다만 SSR 단계에서 무작위 값을 만들면 서버 HTML과 클라이언트 hydration 결과가 달라질 수 있으므로, 브라우저 마운트 이후 게시물 사이 한 지점을 한 번 산출해 삽입한다. 광고는 첫 항목 앞이나 마지막 항목 뒤가 아니라 게시물 사이에만 들어가도록 제한한다.
## 2026-06-05 v1.5.71 — 화면 위치 광고 슬롯은 사이트 코드와 분리 관리 ## 2026-06-05 v1.5.71 — 화면 위치 광고 슬롯은 사이트 코드와 분리 관리
사이트 검증·공통 스크립트용 사이트 코드 카드와, 실제 화면 위치에 삽입되는 광고 단위를 분리했다. 이유는 ads.txt/head/footer는 전역 설정이고, 메인 피드·사이드바·게시물 본문 상단·하단 광고는 위치별 표시 여부와 레이아웃 책임이 다르기 때문이다. 광고 HTML은 관리자 신뢰 콘텐츠로 저장하되, 빈 값은 DOM을 만들지 않고 클라이언트에서만 삽입해 AdSense 스크립트 중복 실행 가능성을 줄인다. 사이트 검증·공통 스크립트용 사이트 코드 카드와, 실제 화면 위치에 삽입되는 광고 단위를 분리했다. 이유는 ads.txt/head/footer는 전역 설정이고, 메인 피드·사이드바·게시물 본문 상단·하단 광고는 위치별 표시 여부와 레이아웃 책임이 다르기 때문이다. 광고 HTML은 관리자 신뢰 콘텐츠로 저장하되, 빈 값은 DOM을 만들지 않고 클라이언트에서만 삽입해 AdSense 스크립트 중복 실행 가능성을 줄인다.

View File

@@ -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·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |

View File

@@ -64,7 +64,7 @@
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다. - 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- `compact`는 썸네일을 포함한 짧은 행 형태, `list`는 텍스트 중심 목록 형태, `cards`는 카드 그리드 형태로 표시한다. - `compact`는 썸네일을 포함한 짧은 행 형태, `list`는 텍스트 중심 목록 형태, `cards`는 카드 그리드 형태로 표시한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다. - Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다. - 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
### Post 페이지 ### Post 페이지
@@ -119,7 +119,7 @@
### 사이트 광고 슬롯 ### 사이트 광고 슬롯
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 위치의 HTML 코드를 저장한다. - 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 다섯 위치의 HTML 코드를 저장한다.
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다. - 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다. - 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다. - 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력 # 업데이트 이력
## v1.5.72
- 사이트 설정 Ads: 메인 인피드 광고 슬롯 추가.
- 메인 화면: Latest 게시물 목록 사이 한 곳에 인피드 광고를 무작위 삽입하도록 추가.
- 사이트 설정 저장소: 메인 인피드 광고 코드 컬럼과 관리자 입력 검증 추가.
## v1.5.71 ## v1.5.71
- 사이트 설정: 위치별 광고 코드를 관리하는 Ads 카드 추가. - 사이트 설정: 위치별 광고 코드를 관리하는 Ads 카드 추가.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -137,6 +137,7 @@ const siteCodeSnapshot = reactive({
/** 편집 시작 시점의 광고 슬롯(취소 시 복원용) */ /** 편집 시작 시점의 광고 슬롯(취소 시 복원용) */
const adsSnapshot = reactive({ const adsSnapshot = reactive({
adHomeFeedCode: '', adHomeFeedCode: '',
adHomeInfeedCode: '',
adSidebarCode: '', adSidebarCode: '',
adPostTopCode: '', adPostTopCode: '',
adPostBottomCode: '' adPostBottomCode: ''
@@ -178,6 +179,7 @@ const form = reactive({
customHeadCode: settings.value?.customHeadCode || '', customHeadCode: settings.value?.customHeadCode || '',
customFooterCode: settings.value?.customFooterCode || '', customFooterCode: settings.value?.customFooterCode || '',
adHomeFeedCode: settings.value?.adHomeFeedCode || '', adHomeFeedCode: settings.value?.adHomeFeedCode || '',
adHomeInfeedCode: settings.value?.adHomeInfeedCode || '',
adSidebarCode: settings.value?.adSidebarCode || '', adSidebarCode: settings.value?.adSidebarCode || '',
adPostTopCode: settings.value?.adPostTopCode || '', adPostTopCode: settings.value?.adPostTopCode || '',
adPostBottomCode: settings.value?.adPostBottomCode || '' adPostBottomCode: settings.value?.adPostBottomCode || ''
@@ -271,6 +273,7 @@ const hasSiteCodeChanges = computed(() => editSiteCode.value && (
*/ */
const hasAdsChanges = computed(() => editAds.value && ( const hasAdsChanges = computed(() => editAds.value && (
form.adHomeFeedCode !== adsSnapshot.adHomeFeedCode form.adHomeFeedCode !== adsSnapshot.adHomeFeedCode
|| form.adHomeInfeedCode !== adsSnapshot.adHomeInfeedCode
|| form.adSidebarCode !== adsSnapshot.adSidebarCode || form.adSidebarCode !== adsSnapshot.adSidebarCode
|| form.adPostTopCode !== adsSnapshot.adPostTopCode || form.adPostTopCode !== adsSnapshot.adPostTopCode
|| form.adPostBottomCode !== adsSnapshot.adPostBottomCode || form.adPostBottomCode !== adsSnapshot.adPostBottomCode
@@ -1128,6 +1131,7 @@ const buildSiteSettingsPayload = () => ({
customHeadCode: form.customHeadCode || '', customHeadCode: form.customHeadCode || '',
customFooterCode: form.customFooterCode || '', customFooterCode: form.customFooterCode || '',
adHomeFeedCode: form.adHomeFeedCode || '', adHomeFeedCode: form.adHomeFeedCode || '',
adHomeInfeedCode: form.adHomeInfeedCode || '',
adSidebarCode: form.adSidebarCode || '', adSidebarCode: form.adSidebarCode || '',
adPostTopCode: form.adPostTopCode || '', adPostTopCode: form.adPostTopCode || '',
adPostBottomCode: form.adPostBottomCode || '' adPostBottomCode: form.adPostBottomCode || ''
@@ -1720,6 +1724,7 @@ const saveSiteCodeSection = async () => {
*/ */
const beginEditAds = () => { const beginEditAds = () => {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode adsSnapshot.adPostBottomCode = form.adPostBottomCode
@@ -1732,6 +1737,7 @@ const beginEditAds = () => {
*/ */
const cancelEditAds = () => { const cancelEditAds = () => {
form.adHomeFeedCode = adsSnapshot.adHomeFeedCode form.adHomeFeedCode = adsSnapshot.adHomeFeedCode
form.adHomeInfeedCode = adsSnapshot.adHomeInfeedCode
form.adSidebarCode = adsSnapshot.adSidebarCode form.adSidebarCode = adsSnapshot.adSidebarCode
form.adPostTopCode = adsSnapshot.adPostTopCode form.adPostTopCode = adsSnapshot.adPostTopCode
form.adPostBottomCode = adsSnapshot.adPostBottomCode form.adPostBottomCode = adsSnapshot.adPostBottomCode
@@ -1754,6 +1760,7 @@ const saveAdsSection = async () => {
if (ok) { if (ok) {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode adsSnapshot.adPostBottomCode = form.adPostBottomCode

View File

@@ -15,6 +15,7 @@ const postFeedStyleStorageKey = 'POST_FEED_STYLE'
const postFeedStyleOpen = ref(false) const postFeedStyleOpen = ref(false)
const postFeedStyle = ref('compact') const postFeedStyle = ref('compact')
const homeInfeedAdInsertIndex = ref(-1)
/** @typedef {'list' | 'compact' | 'cards'} PostFeedStyle */ /** @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 featuredPosts = computed(() => posts.value.filter((post) => post.isFeatured).slice(0, 6))
const latestPosts = computed(() => posts.value.map(mapLatestPost)) const latestPosts = computed(() => posts.value.map(mapLatestPost))
const hasHomeInfeedAd = computed(() => Boolean(String(siteSettings.value?.adHomeInfeedCode || '').trim()))
const featuredTrackRef = ref(null) const featuredTrackRef = ref(null)
/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */ /** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */
@@ -236,6 +238,7 @@ onMounted(() => {
const storedStyle = localStorage.getItem(postFeedStyleStorageKey) const storedStyle = localStorage.getItem(postFeedStyleStorageKey)
postFeedStyle.value = normalizePostFeedStyle(storedStyle) postFeedStyle.value = normalizePostFeedStyle(storedStyle)
randomizeHomeInfeedAdInsertIndex()
document.addEventListener('pointerdown', onDocumentPointerDown) document.addEventListener('pointerdown', onDocumentPointerDown)
}) })
@@ -277,6 +280,29 @@ const scrollFeatured = (direction) => {
behavior: 'smooth' 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()
})
</script> </script>
<template> <template>
@@ -465,89 +491,100 @@ const scrollFeatured = (direction) => {
data-post-feed="latest" data-post-feed="latest"
:class="getPostFeedContainerClass(postFeedStyle)" :class="getPostFeedContainerClass(postFeedStyle)"
> >
<article <template
v-for="post in latestPosts" v-for="(post, index) in latestPosts"
:key="post.to" :key="post.to"
data-post-card
:data-featured="post.isFeatured ? '' : undefined"
:class="getPostFeedArticleClass(postFeedStyle)"
> >
<PostCardMedia <article
v-if="showPostFeedMedia" data-post-card
:to="post.to" :data-featured="post.isFeatured ? '' : undefined"
:title="post.title" :class="getPostFeedArticleClass(postFeedStyle)"
:featured-image="post.featuredImage"
:link-class="isPostFeedCards ? 'post-feed__media post-feed__media--cards mb-3 block aspect-video w-full' : 'post-feed__media post-feed__media--list relative flex-1 aspect-square min-w-16 sm:aspect-video'"
:aspect-class="isPostFeedCards ? 'aspect-video' : 'aspect-square sm:aspect-video'"
/>
<div
class="post-feed__content relative min-w-0"
:class="isPostFeedCards ? 'flex flex-col' : 'flex flex-[3] flex-col gap-1.5 md:flex-[4]'"
> >
<div class="post-feed__content-inner flex min-h-0 flex-1 flex-col gap-1"> <PostCardMedia
<h2 class="max-w-[90%] text-sm font-medium leading-tight"> v-if="showPostFeedMedia"
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75"> :to="post.to"
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]"> :title="post.title"
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> :featured-image="post.featuredImage"
<path d="M13 3v7h6l-8 11v-7H5l8-11" /> :link-class="isPostFeedCards ? 'post-feed__media post-feed__media--cards mb-3 block aspect-video w-full' : 'post-feed__media post-feed__media--list relative flex-1 aspect-square min-w-16 sm:aspect-video'"
</svg> :aspect-class="isPostFeedCards ? 'aspect-video' : 'aspect-square sm:aspect-video'"
</span> />
{{ post.title }}
</NuxtLink>
</h2>
<p <div
v-if="post.excerpt" class="post-feed__content relative min-w-0"
class="flex-1 text-[0.8rem] leading-tight site-muted text-[#6E6661]" :class="isPostFeedCards ? 'flex flex-col' : 'flex flex-[3] flex-col gap-1.5 md:flex-[4]'"
:class="getPostFeedExcerptClass(postFeedStyle)"
>
{{ post.excerpt }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
<template v-if="post.tagName">
<span v-if="post.publishedAt" class="text-[var(--site-line)]">/</span>
<span
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
:style="{ backgroundColor: `${post.tagColor}1a` }"
>
{{ post.tagName }}
</span>
</template>
<span v-if="post.publishedAt || post.tagName" class="text-[var(--site-line)]">/</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<span>{{ post.commentCount }}</span>
</span>
</div>
</div>
<button
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75"
:class="isPostFeedCards ? '' : 'md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100'"
type="button"
aria-label="Share this post"
> >
<svg <div class="post-feed__content-inner flex min-h-0 flex-1 flex-col gap-1">
xmlns="http://www.w3.org/2000/svg" <h2 class="max-w-[90%] text-sm font-medium leading-tight">
viewBox="0 0 24 24" <NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
fill="none" <span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
stroke="currentColor" <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
stroke-width="2" <path d="M13 3v7h6l-8 11v-7H5l8-11" />
stroke-linecap="round" </svg>
stroke-linejoin="round" </span>
class="h-4 w-4" {{ post.title }}
</NuxtLink>
</h2>
<p
v-if="post.excerpt"
class="flex-1 text-[0.8rem] leading-tight site-muted text-[#6E6661]"
:class="getPostFeedExcerptClass(postFeedStyle)"
>
{{ post.excerpt }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
<template v-if="post.tagName">
<span v-if="post.publishedAt" class="text-[var(--site-line)]">/</span>
<span
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
:style="{ backgroundColor: `${post.tagColor}1a` }"
>
{{ post.tagName }}
</span>
</template>
<span v-if="post.publishedAt || post.tagName" class="text-[var(--site-line)]">/</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<span>{{ post.commentCount }}</span>
</span>
</div>
</div>
<button
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75"
:class="isPostFeedCards ? '' : 'md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100'"
type="button"
aria-label="Share this post"
> >
<path d="M17 7 7 17" /> <svg
<path d="M8 7h9v9" /> xmlns="http://www.w3.org/2000/svg"
</svg> viewBox="0 0 24 24"
</button> fill="none"
</div> stroke="currentColor"
</article> stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
>
<path d="M17 7 7 17" />
<path d="M8 7h9v9" />
</svg>
</button>
</div>
</article>
<SiteAdSlot
v-if="index === homeInfeedAdInsertIndex"
class="home-page__infeed-ad-slot py-3"
:class="isPostFeedCards ? 'sm:col-span-2' : ''"
:code="siteSettings?.adHomeInfeedCode"
location="home-infeed"
/>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -125,6 +125,7 @@ const mapSiteSettingsRow = (row) => ({
customHeadCode: row.custom_head_code || '', customHeadCode: row.custom_head_code || '',
customFooterCode: row.custom_footer_code || '', customFooterCode: row.custom_footer_code || '',
adHomeFeedCode: row.ad_home_feed_code || '', adHomeFeedCode: row.ad_home_feed_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 || '',
adPostBottomCode: row.ad_post_bottom_code || '', adPostBottomCode: row.ad_post_bottom_code || '',
@@ -899,6 +900,7 @@ export const updateSiteSettings = async (input) => {
custom_head_code, custom_head_code,
custom_footer_code, custom_footer_code,
ad_home_feed_code, ad_home_feed_code,
ad_home_infeed_code,
ad_sidebar_code, ad_sidebar_code,
ad_post_top_code, ad_post_top_code,
ad_post_bottom_code, ad_post_bottom_code,
@@ -930,6 +932,7 @@ export const updateSiteSettings = async (input) => {
${input.customHeadCode || ''}, ${input.customHeadCode || ''},
${input.customFooterCode || ''}, ${input.customFooterCode || ''},
${input.adHomeFeedCode || ''}, ${input.adHomeFeedCode || ''},
${input.adHomeInfeedCode || ''},
${input.adSidebarCode || ''}, ${input.adSidebarCode || ''},
${input.adPostTopCode || ''}, ${input.adPostTopCode || ''},
${input.adPostBottomCode || ''}, ${input.adPostBottomCode || ''},
@@ -961,6 +964,7 @@ export const updateSiteSettings = async (input) => {
custom_head_code = EXCLUDED.custom_head_code, custom_head_code = EXCLUDED.custom_head_code,
custom_footer_code = EXCLUDED.custom_footer_code, custom_footer_code = EXCLUDED.custom_footer_code,
ad_home_feed_code = EXCLUDED.ad_home_feed_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_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_bottom_code = EXCLUDED.ad_post_bottom_code, ad_post_bottom_code = EXCLUDED.ad_post_bottom_code,

View File

@@ -47,6 +47,7 @@ export const adminSiteSettingsInputSchema = z.object({
customHeadCode: z.string().max(50000).optional().default(''), customHeadCode: z.string().max(50000).optional().default(''),
customFooterCode: z.string().max(50000).optional().default(''), customFooterCode: z.string().max(50000).optional().default(''),
adHomeFeedCode: 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(''), adSidebarCode: z.string().max(50000).optional().default(''),
adPostTopCode: z.string().max(50000).optional().default(''), adPostTopCode: z.string().max(50000).optional().default(''),
adPostBottomCode: z.string().max(50000).optional().default('') adPostBottomCode: z.string().max(50000).optional().default('')

View File

@@ -38,6 +38,7 @@ export const getDefaultSiteSettings = () => {
customHeadCode: '', customHeadCode: '',
customFooterCode: '', customFooterCode: '',
adHomeFeedCode: '', adHomeFeedCode: '',
adHomeInfeedCode: '',
adSidebarCode: '', adSidebarCode: '',
adPostTopCode: '', adPostTopCode: '',
adPostBottomCode: '', adPostBottomCode: '',