사이트 광고 슬롯 설정 추가

This commit is contained in:
2026-06-05 15:43:57 +09:00
parent 928b8446b4
commit 9a4820e69c
16 changed files with 408 additions and 7 deletions

View File

@@ -0,0 +1,155 @@
<script setup>
const adSlots = [
{
key: 'adHomeFeedCode',
label: '메인 피드',
description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adSidebarCode',
label: '오른쪽 사이드',
description: '오른쪽 사이드바 하단 영역에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostTopCode',
label: '게시물 본문 상단',
description: '게시물 상세 본문 렌더링 직전에 표시됩니다.',
placeholder: '<script async src="..."><' + '/script>\n<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostBottomCode',
label: '게시물 본문 하단',
description: '게시물 상세 본문 렌더링 직후에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>\n<script>(adsbygoogle = window.adsbygoogle || []).push({})<' + '/script>'
}
]
/**
* 광고 슬롯 코드 등록 여부를 반환한다.
* @param {Object} form - 사이트 설정 폼
* @param {string} key - 슬롯 키
* @returns {boolean} 등록 여부
*/
const hasSlotCode = (form, key) => Boolean(String(form?.[key] || '').trim())
/**
* 광고 설정 카드
* @property {Object} form - 사이트 설정 폼 객체
* @property {boolean} editing - 편집 모드 여부
* @property {boolean} saving - 저장 중 여부
* @property {boolean} hasChanges - 변경 여부
*/
defineProps({
form: {
type: Object,
required: true
},
editing: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
hasChanges: {
type: Boolean,
default: false
}
})
defineEmits(['begin', 'cancel', 'save'])
</script>
<template>
<section
id="admin-settings-section-ads"
class="admin-ads-settings-card admin-settings-screen__card admin-settings-screen__card--ads relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
Ads
</h2>
<p
v-if="!editing"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
위치별 광고 코드를 관리합니다. 비어 있는 슬롯은 공개 화면에 표시되지 않습니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!editing">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="$emit('begin')"
>
편집
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="saving"
@click="$emit('cancel')"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="saving || !hasChanges"
@click="$emit('save')"
>
{{ saving ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div
v-if="!editing"
class="admin-ads-settings-card__readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<div
v-for="slot in adSlots"
:key="slot.key"
class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[9rem_minmax(0,1fr)] md:items-center md:gap-5"
>
<p class="font-normal text-[#3f4650]">
{{ slot.label }}
</p>
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
{{ hasSlotCode(form, slot.key) ? '등록됨' : '미등록' }}
</p>
</div>
</div>
<div
v-else
class="admin-ads-settings-card__edit grid gap-5 border-t border-[#eceff2] pt-5"
>
<label
v-for="slot in adSlots"
:key="slot.key"
class="admin-settings-screen__field grid gap-2 text-sm"
>
<span class="font-medium text-[#3f4650]">{{ slot.label }}</span>
<p class="text-xs leading-relaxed text-[#657080]">
{{ slot.description }} 애드센스에서 제공한 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣습니다.
</p>
<textarea
v-model="form[slot.key]"
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
rows="7"
spellcheck="false"
:placeholder="slot.placeholder"
/>
</label>
</div>
</section>
</template>

View File

@@ -486,6 +486,12 @@ watch([postTocItems, () => route.fullPath], async () => {
About {{ siteSettings.title }}
</NuxtLink>
</div>
<SiteAdSlot
class="right-sidebar__ad-slot site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0"
:code="siteSettings?.adSidebarCode"
location="sidebar"
/>
</div>
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">

View File

@@ -0,0 +1,79 @@
<script setup>
const props = defineProps({
code: {
type: String,
default: ''
},
location: {
type: String,
default: ''
}
})
const slotRef = ref(null)
const mounted = ref(false)
const normalizedCode = computed(() => String(props.code || '').trim())
/**
* v-html로 삽입된 광고 스크립트를 브라우저에서 실행 가능한 노드로 교체한다.
* @returns {void}
*/
const executeAdScripts = () => {
if (!import.meta.client || !(slotRef.value instanceof HTMLElement)) {
return
}
const scripts = Array.from(slotRef.value.querySelectorAll('script'))
scripts.forEach((script) => {
const nextScript = document.createElement('script')
Array.from(script.attributes).forEach((attribute) => {
nextScript.setAttribute(attribute.name, attribute.value)
})
nextScript.text = script.text || script.textContent || ''
script.replaceWith(nextScript)
})
}
watch(normalizedCode, async () => {
await nextTick()
executeAdScripts()
})
onMounted(async () => {
mounted.value = true
await nextTick()
executeAdScripts()
})
</script>
<template>
<div
v-if="mounted && normalizedCode"
ref="slotRef"
class="site-ad-slot"
role="complementary"
aria-label="광고"
:data-ad-location="location || undefined"
v-html="normalizedCode"
/>
</template>
<style scoped>
.site-ad-slot {
width: 100%;
max-width: 100%;
overflow: hidden;
}
.site-ad-slot :deep(ins.adsbygoogle) {
display: block;
max-width: 100%;
}
.site-ad-slot :deep(iframe) {
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,5 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS ad_home_feed_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ad_sidebar_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ad_post_top_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ad_post_bottom_code TEXT NOT NULL DEFAULT '';

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-05 v1.5.71 — 화면 위치 광고 슬롯은 사이트 코드와 분리 관리
사이트 검증·공통 스크립트용 사이트 코드 카드와, 실제 화면 위치에 삽입되는 광고 단위를 분리했다. 이유는 ads.txt/head/footer는 전역 설정이고, 메인 피드·사이드바·게시물 본문 상단·하단 광고는 위치별 표시 여부와 레이아웃 책임이 다르기 때문이다. 광고 HTML은 관리자 신뢰 콘텐츠로 저장하되, 빈 값은 DOM을 만들지 않고 클라이언트에서만 삽입해 AdSense 스크립트 중복 실행 가능성을 줄인다.
## 2026-06-05 v1.5.70 — 라이브 멀티라인 Enter는 DOM 조각 삽입 대신 텍스트 값을 갱신한다
콜아웃·인용 본문은 `white-space: pre-wrap`인 plain text contenteditable로 관리한다. Range에 직접 텍스트 노드를 삽입하면 줄바꿈 직후 브라우저 IME 조합 위치가 불안정해져 한글 첫 글자가 자모로 분리될 수 있다. 멀티라인 Enter는 전체 텍스트 값을 기준으로 선택 범위를 `\n`으로 교체하고 커서를 텍스트 오프셋으로 다시 배치한다. 또한 Selection Bridge의 Range 교차 판정을 바로잡고, 콜아웃·인용 전체 선택 삭제는 블록 자체가 아니라 빈 본문 줄을 남기도록 한다.

View File

@@ -77,6 +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/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
@@ -90,6 +91,7 @@
|------|-----------|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
| 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·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |

View File

@@ -64,10 +64,12 @@
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- `compact`는 썸네일을 포함한 짧은 행 형태, `list`는 텍스트 중심 목록 형태, `cards`는 카드 그리드 형태로 표시한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
### Post 페이지
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
- 사이트 설정 Ads의 게시물 본문 상단·하단 광고 코드가 있으면 본문 렌더링 전후에 광고 슬롯을 표시한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
@@ -115,6 +117,13 @@
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
### 사이트 광고 슬롯
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 네 위치의 HTML 코드를 저장한다.
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
### 공개 인증 화면(초기)
- 회원가입 화면은 AFFiNE 참고 다크 테마 3단계 플로우를 제공한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.5.71
- 사이트 설정: 위치별 광고 코드를 관리하는 Ads 카드 추가.
- 공개 화면: 메인 Featured·Latest 사이, 오른쪽 사이드 하단, 게시물 본문 상단·하단 광고 슬롯 추가.
- 사이트 설정 저장소: 광고 슬롯 코드 컬럼과 관리자 입력 검증 추가.
## v1.5.70
- 게시물 글쓰기: 라이브 모드 마지막 줄 `!!!` Enter 콜아웃 단축 생성 시 본문 빈 줄을 두 줄로 만들도록 수정.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -33,6 +33,7 @@ const savingBrand = ref(false)
const savingAnnouncement = ref(false)
const savingSpam = ref(false)
const savingSiteCode = ref(false)
const savingAds = ref(false)
const uploadingLogo = ref(false)
const uploadingHomeCover = ref(false)
const uploadingHomeCoverDark = ref(false)
@@ -81,6 +82,8 @@ const customizeAnnouncement = ref(false)
const editSpam = ref(false)
/** 사이트 코드 카드 편집 모드 여부 */
const editSiteCode = ref(false)
/** 광고 슬롯 카드 편집 모드 여부 */
const editAds = ref(false)
/** 편집 시작 시점의 제목·설명(취소 시 복원용) */
const titleDescSnapshot = reactive({
title: '',
@@ -131,6 +134,13 @@ const siteCodeSnapshot = reactive({
customHeadCode: '',
customFooterCode: ''
})
/** 편집 시작 시점의 광고 슬롯(취소 시 복원용) */
const adsSnapshot = reactive({
adHomeFeedCode: '',
adSidebarCode: '',
adPostTopCode: '',
adPostBottomCode: ''
})
let toastTimer = null
let scrollSpyFrame = null
let postExportRefreshTimer = null
@@ -166,7 +176,11 @@ const form = reactive({
signupBlockedUsernames: normalizeSignupBlockedUsernames(settings.value?.signupBlockedUsernames),
adsTxt: settings.value?.adsTxt || '',
customHeadCode: settings.value?.customHeadCode || '',
customFooterCode: settings.value?.customFooterCode || ''
customFooterCode: settings.value?.customFooterCode || '',
adHomeFeedCode: settings.value?.adHomeFeedCode || '',
adSidebarCode: settings.value?.adSidebarCode || '',
adPostTopCode: settings.value?.adPostTopCode || '',
adPostBottomCode: settings.value?.adPostBottomCode || ''
})
/**
@@ -251,6 +265,17 @@ const hasSiteCodeChanges = computed(() => editSiteCode.value && (
|| form.customFooterCode !== siteCodeSnapshot.customFooterCode
))
/**
* 광고 슬롯 변경 여부
* @returns {boolean} 변경 여부
*/
const hasAdsChanges = computed(() => editAds.value && (
form.adHomeFeedCode !== adsSnapshot.adHomeFeedCode
|| form.adSidebarCode !== adsSnapshot.adSidebarCode
|| form.adPostTopCode !== adsSnapshot.adPostTopCode
|| form.adPostBottomCode !== adsSnapshot.adPostBottomCode
))
/**
* 최신 게시물 export 작업 목록
* @returns {Array} export 작업 목록
@@ -520,7 +545,8 @@ const settingsNavGroups = [
{ id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image', iconId: 'home-cover' },
{ id: 'admin-settings-section-brand', label: '브랜드', keywords: 'brand design accent color point 포인트 컬러', iconId: 'site-code' },
{ id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice', iconId: 'announcement' },
{ id: 'admin-settings-section-site-code', label: '사이트 코드', keywords: 'ads ads.txt head footer script code adsense', iconId: 'site-code' }
{ id: 'admin-settings-section-site-code', label: '사이트 코드', keywords: 'ads ads.txt head footer script code adsense', iconId: 'site-code' },
{ id: 'admin-settings-section-ads', label: 'Ads', keywords: 'ads ad slot advertisement adsense 광고 애드센스', iconId: 'site-code' }
]
},
{
@@ -1100,7 +1126,11 @@ const buildSiteSettingsPayload = () => ({
signupBlockedUsernames: normalizeSignupBlockedUsernames(form.signupBlockedUsernames),
adsTxt: form.adsTxt || '',
customHeadCode: form.customHeadCode || '',
customFooterCode: form.customFooterCode || ''
customFooterCode: form.customFooterCode || '',
adHomeFeedCode: form.adHomeFeedCode || '',
adSidebarCode: form.adSidebarCode || '',
adPostTopCode: form.adPostTopCode || '',
adPostBottomCode: form.adPostBottomCode || ''
})
/**
@@ -1684,6 +1714,53 @@ const saveSiteCodeSection = async () => {
}
}
/**
* 광고 슬롯 편집 모드 진입
* @returns {void}
*/
const beginEditAds = () => {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode
editAds.value = true
}
/**
* 광고 슬롯 편집 취소
* @returns {void}
*/
const cancelEditAds = () => {
form.adHomeFeedCode = adsSnapshot.adHomeFeedCode
form.adSidebarCode = adsSnapshot.adSidebarCode
form.adPostTopCode = adsSnapshot.adPostTopCode
form.adPostBottomCode = adsSnapshot.adPostBottomCode
editAds.value = false
}
/**
* 광고 슬롯 설정 저장
* @returns {Promise<void>}
*/
const saveAdsSection = async () => {
if (!hasAdsChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '광고 슬롯 설정이 저장되었습니다.',
savingFlag: savingAds
})
if (ok) {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode
editAds.value = false
}
}
/**
* Escape 키: 제목·설명 편집 중이면 취소, 아니면 설정 화면 닫기
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -1728,6 +1805,11 @@ const onGlobalKeydown = (event) => {
cancelEditSiteCode()
return
}
if (editAds.value) {
event.preventDefault()
cancelEditAds()
return
}
closeSettings()
}
@@ -2919,6 +3001,16 @@ onBeforeUnmount(() => {
@save="saveSiteCodeSection"
/>
<AdminAdsSettingsCard
:form="form"
:editing="editAds"
:saving="savingAds"
:has-changes="hasAdsChanges"
@begin="beginEditAds"
@cancel="cancelEditAds"
@save="saveAdsSection"
/>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
콘텐츠·안전
</h2>

View File

@@ -348,6 +348,12 @@ const scrollFeatured = (direction) => {
</div>
</section>
<SiteAdSlot
class="home-page__ad-slot px-6 py-4"
:code="siteSettings?.adHomeFeedCode"
location="home-feed"
/>
<section class="latest-posts-section min-h-[360px] py-4 px-6">
<div class="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">

View File

@@ -15,6 +15,9 @@ const { data: tags } = await useFetch('/api/tags', {
const { data: posts } = await useFetch('/api/posts', {
default: () => []
})
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const postToc = useState('post-detail-toc', () => [])
if (!post.value) {
@@ -338,9 +341,19 @@ useHead(() => ({
<section>
<div class="mx-auto max-w-[720px] px-4 sm:px-5">
<SiteAdSlot
class="post-detail__ad-slot post-detail__ad-slot--top mb-8"
:code="siteSettings?.adPostTopCode"
location="post-top"
/>
<ContentRenderer>
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
</ContentRenderer>
<SiteAdSlot
class="post-detail__ad-slot post-detail__ad-slot--bottom mt-8"
:code="siteSettings?.adPostBottomCode"
location="post-bottom"
/>
</div>
</section>

View File

@@ -124,6 +124,10 @@ const mapSiteSettingsRow = (row) => ({
adsTxt: row.ads_txt || '',
customHeadCode: row.custom_head_code || '',
customFooterCode: row.custom_footer_code || '',
adHomeFeedCode: row.ad_home_feed_code || '',
adSidebarCode: row.ad_sidebar_code || '',
adPostTopCode: row.ad_post_top_code || '',
adPostBottomCode: row.ad_post_bottom_code || '',
updatedAt: row.updated_at.toISOString()
})
@@ -894,6 +898,10 @@ export const updateSiteSettings = async (input) => {
ads_txt,
custom_head_code,
custom_footer_code,
ad_home_feed_code,
ad_sidebar_code,
ad_post_top_code,
ad_post_bottom_code,
updated_at
)
VALUES (
@@ -921,6 +929,10 @@ export const updateSiteSettings = async (input) => {
${input.adsTxt || ''},
${input.customHeadCode || ''},
${input.customFooterCode || ''},
${input.adHomeFeedCode || ''},
${input.adSidebarCode || ''},
${input.adPostTopCode || ''},
${input.adPostBottomCode || ''},
now()
)
ON CONFLICT (id) DO UPDATE
@@ -948,6 +960,10 @@ export const updateSiteSettings = async (input) => {
ads_txt = EXCLUDED.ads_txt,
custom_head_code = EXCLUDED.custom_head_code,
custom_footer_code = EXCLUDED.custom_footer_code,
ad_home_feed_code = EXCLUDED.ad_home_feed_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,
updated_at = now()
RETURNING *
`

View File

@@ -45,7 +45,11 @@ export const adminSiteSettingsInputSchema = z.object({
).max(MAX_SIGNUP_BLOCKED_USERNAME_COUNT).optional().default([...DEFAULT_SIGNUP_BLOCKED_USERNAMES]),
adsTxt: z.string().max(20000).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(''),
adSidebarCode: z.string().max(50000).optional().default(''),
adPostTopCode: z.string().max(50000).optional().default(''),
adPostBottomCode: z.string().max(50000).optional().default('')
}).superRefine((data, ctx) => {
if (!isValidAnnouncementBackgroundColor(data.announcementBackgroundColor)) {
ctx.addIssue({

View File

@@ -37,6 +37,10 @@ export const getDefaultSiteSettings = () => {
adsTxt: '',
customHeadCode: '',
customFooterCode: '',
adHomeFeedCode: '',
adSidebarCode: '',
adPostTopCode: '',
adPostBottomCode: '',
updatedAt: null
}
}