게시물 OG 이미지 설정 추가
This commit is contained in:
@@ -21,8 +21,10 @@ const slugTouched = ref(Boolean(props.initialPost.slug))
|
|||||||
const blockEditor = ref(null)
|
const blockEditor = ref(null)
|
||||||
const mediaItems = ref([])
|
const mediaItems = ref([])
|
||||||
const isMediaPickerOpen = ref(false)
|
const isMediaPickerOpen = ref(false)
|
||||||
|
const mediaPickerTarget = ref('featuredImage')
|
||||||
const isLoadingMedia = ref(false)
|
const isLoadingMedia = ref(false)
|
||||||
const isUploadingFeaturedImage = ref(false)
|
const isUploadingFeaturedImage = ref(false)
|
||||||
|
const isUploadingOgImage = ref(false)
|
||||||
const autosaveTimer = ref(null)
|
const autosaveTimer = ref(null)
|
||||||
const autosaveNotice = ref(null)
|
const autosaveNotice = ref(null)
|
||||||
const autosaveStatus = ref('')
|
const autosaveStatus = ref('')
|
||||||
@@ -78,6 +80,7 @@ const form = reactive({
|
|||||||
seoDescription: props.initialPost.seoDescription || '',
|
seoDescription: props.initialPost.seoDescription || '',
|
||||||
canonicalUrl: props.initialPost.canonicalUrl || '',
|
canonicalUrl: props.initialPost.canonicalUrl || '',
|
||||||
noindex: Boolean(props.initialPost.noindex),
|
noindex: Boolean(props.initialPost.noindex),
|
||||||
|
ogImage: props.initialPost.ogImage || '',
|
||||||
status: props.initialPost.status || 'draft',
|
status: props.initialPost.status || 'draft',
|
||||||
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
||||||
tagsText: props.initialPost.tags?.join(', ') || ''
|
tagsText: props.initialPost.tags?.join(', ') || ''
|
||||||
@@ -152,6 +155,7 @@ const createPostPayload = () => {
|
|||||||
seoDescription: form.seoDescription.trim(),
|
seoDescription: form.seoDescription.trim(),
|
||||||
canonicalUrl: form.canonicalUrl.trim(),
|
canonicalUrl: form.canonicalUrl.trim(),
|
||||||
noindex: form.noindex,
|
noindex: form.noindex,
|
||||||
|
ogImage: form.ogImage.trim() || null,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
tags: parseTags(form.tagsText)
|
tags: parseTags(form.tagsText)
|
||||||
@@ -172,6 +176,7 @@ const createAutosavePayload = () => ({
|
|||||||
seoDescription: form.seoDescription,
|
seoDescription: form.seoDescription,
|
||||||
canonicalUrl: form.canonicalUrl,
|
canonicalUrl: form.canonicalUrl,
|
||||||
noindex: form.noindex,
|
noindex: form.noindex,
|
||||||
|
ogImage: form.ogImage,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
publishedAt: form.publishedAt,
|
publishedAt: form.publishedAt,
|
||||||
tagsText: form.tagsText
|
tagsText: form.tagsText
|
||||||
@@ -188,6 +193,10 @@ const isEmptyAutosavePayload = (payload) => ![
|
|||||||
payload.excerpt,
|
payload.excerpt,
|
||||||
payload.content,
|
payload.content,
|
||||||
payload.featuredImage,
|
payload.featuredImage,
|
||||||
|
payload.seoTitle,
|
||||||
|
payload.seoDescription,
|
||||||
|
payload.canonicalUrl,
|
||||||
|
payload.ogImage,
|
||||||
payload.tagsText
|
payload.tagsText
|
||||||
].some((value) => String(value || '').trim())
|
].some((value) => String(value || '').trim())
|
||||||
|
|
||||||
@@ -293,7 +302,8 @@ const fetchMediaItems = async () => {
|
|||||||
* 대표 이미지 선택 창 열기
|
* 대표 이미지 선택 창 열기
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const openMediaPicker = async () => {
|
const openMediaPicker = async (target = 'featuredImage') => {
|
||||||
|
mediaPickerTarget.value = target
|
||||||
isMediaPickerOpen.value = true
|
isMediaPickerOpen.value = true
|
||||||
await fetchMediaItems()
|
await fetchMediaItems()
|
||||||
}
|
}
|
||||||
@@ -311,8 +321,8 @@ const closeMediaPicker = () => {
|
|||||||
* @param {Object} item - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const selectFeaturedImage = (item) => {
|
const selectPickedImage = (item) => {
|
||||||
form.featuredImage = item.url
|
form[mediaPickerTarget.value] = item.url
|
||||||
closeMediaPicker()
|
closeMediaPicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +334,14 @@ const removeFeaturedImage = () => {
|
|||||||
form.featuredImage = ''
|
form.featuredImage = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OG 이미지 삭제
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeOgImage = () => {
|
||||||
|
form.ogImage = ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대표 이미지 파일 업로드
|
* 대표 이미지 파일 업로드
|
||||||
* @param {Event} event - 파일 입력 이벤트
|
* @param {Event} event - 파일 입력 이벤트
|
||||||
@@ -352,6 +370,34 @@ const uploadFeaturedImage = async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OG 이미지 파일 업로드
|
||||||
|
* @param {Event} event - 파일 입력 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const uploadOgImage = async (event) => {
|
||||||
|
const files = event.target.files
|
||||||
|
|
||||||
|
if (!files?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', files[0])
|
||||||
|
isUploadingOgImage.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/admin/api/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
form.ogImage = result.files?.[0]?.url || ''
|
||||||
|
} finally {
|
||||||
|
event.target.value = ''
|
||||||
|
isUploadingOgImage.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 제목 입력 후 본문 에디터로 이동
|
* 제목 입력 후 본문 에디터로 이동
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -557,7 +603,7 @@ defineExpose({
|
|||||||
{{ form.featuredImage }}
|
{{ form.featuredImage }}
|
||||||
</p>
|
</p>
|
||||||
<div class="admin-post-form__featured-buttons flex flex-wrap gap-2">
|
<div class="admin-post-form__featured-buttons flex flex-wrap gap-2">
|
||||||
<button class="admin-post-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker">
|
<button class="admin-post-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker('featuredImage')">
|
||||||
변경
|
변경
|
||||||
</button>
|
</button>
|
||||||
<label class="admin-post-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
<label class="admin-post-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||||
@@ -580,6 +626,39 @@ defineExpose({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-post-form__label font-medium">OG 이미지</span>
|
||||||
|
<figure v-if="form.ogImage" class="admin-post-form__og-image overflow-hidden rounded border border-line bg-white">
|
||||||
|
<img class="admin-post-form__og-preview aspect-[1.91/1] w-full bg-surface object-cover" :src="form.ogImage" alt="">
|
||||||
|
<figcaption class="admin-post-form__og-actions grid gap-2 p-3">
|
||||||
|
<p class="admin-post-form__og-url break-all text-xs text-muted">
|
||||||
|
{{ form.ogImage }}
|
||||||
|
</p>
|
||||||
|
<div class="admin-post-form__og-buttons flex flex-wrap gap-2">
|
||||||
|
<button class="admin-post-form__og-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker('ogImage')">
|
||||||
|
변경
|
||||||
|
</button>
|
||||||
|
<label class="admin-post-form__og-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||||
|
새 업로드
|
||||||
|
<input class="sr-only" type="file" accept="image/*" @change="uploadOgImage">
|
||||||
|
</label>
|
||||||
|
<button class="admin-post-form__og-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeOgImage">
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<div v-else class="admin-post-form__og-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
|
||||||
|
<button class="admin-post-form__og-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker('ogImage')">
|
||||||
|
미디어에서 선택
|
||||||
|
</button>
|
||||||
|
<label class="admin-post-form__og-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||||
|
{{ isUploadingOgImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||||
|
<input class="sr-only" type="file" accept="image/*" @change="uploadOgImage">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -609,7 +688,7 @@ defineExpose({
|
|||||||
<section class="admin-post-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
<section class="admin-post-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
||||||
<div class="admin-post-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
<div class="admin-post-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||||
<h2 class="admin-post-form__media-picker-title text-lg font-semibold">
|
<h2 class="admin-post-form__media-picker-title text-lg font-semibold">
|
||||||
대표 이미지 선택
|
{{ mediaPickerTarget === 'ogImage' ? 'OG 이미지 선택' : '대표 이미지 선택' }}
|
||||||
</h2>
|
</h2>
|
||||||
<button class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
<button class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
||||||
닫기
|
닫기
|
||||||
@@ -625,7 +704,7 @@ defineExpose({
|
|||||||
:key="item.url"
|
:key="item.url"
|
||||||
class="admin-post-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
class="admin-post-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectFeaturedImage(item)"
|
@click="selectPickedImage(item)"
|
||||||
>
|
>
|
||||||
<img class="admin-post-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
<img class="admin-post-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||||
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||||
|
|||||||
2
db/migrations/009_add_post_og_image.sql
Normal file
2
db/migrations/009_add_post_og_image.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS og_image TEXT;
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-03 v0.0.30
|
||||||
|
|
||||||
|
### OG 이미지 저장 방식 결정
|
||||||
|
|
||||||
|
게시물 OG 이미지는 대표 이미지와 별도 필드인 `og_image`로 저장한다. 대표 이미지는 화면 카드와 본문 진입 시각 요소에 쓰이고, OG 이미지는 외부 공유 미리보기 비율과 목적이 다를 수 있기 때문이다.
|
||||||
|
|
||||||
|
관리자 입력은 대표 이미지와 같은 미디어 선택/업로드 흐름을 재사용한다. OG 이미지가 비어 있으면 공개 상세 화면에서는 대표 이미지를 fallback으로 사용해 기존 글도 기본 공유 이미지를 가질 수 있게 한다.
|
||||||
|
|
||||||
## 2026-05-03 v0.0.29
|
## 2026-05-03 v0.0.29
|
||||||
|
|
||||||
### 게시물 SEO 설정 범위 결정
|
### 게시물 SEO 설정 범위 결정
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지/OG 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정 |
|
||||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
| pages/index.vue | 홈 |
|
| pages/index.vue | 홈 |
|
||||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO 메타 출력 |
|
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력 |
|
||||||
| pages/tags/index.vue | 태그 전체 목록 |
|
| pages/tags/index.vue | 태그 전체 목록 |
|
||||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||||
| pages/tag/[slug].vue | 태그별 글 목록 |
|
| pages/tag/[slug].vue | 태그별 글 목록 |
|
||||||
@@ -148,6 +148,7 @@
|
|||||||
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
||||||
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
||||||
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
||||||
|
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ components/content/
|
|||||||
| seo_description | String | SEO 설명 |
|
| seo_description | String | SEO 설명 |
|
||||||
| canonical_url | String | canonical URL |
|
| canonical_url | String | canonical URL |
|
||||||
| noindex | Boolean | 검색엔진 노출 제외 여부 |
|
| noindex | Boolean | 검색엔진 노출 제외 여부 |
|
||||||
|
| og_image | String nullable | OG 이미지 |
|
||||||
| status | Enum | published/draft/private |
|
| status | Enum | published/draft/private |
|
||||||
| published_at | DateTime | 발행일 |
|
| published_at | DateTime | 발행일 |
|
||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
@@ -302,6 +303,8 @@ components/content/
|
|||||||
- 글 SEO 설정은 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 여부를 저장한다.
|
- 글 SEO 설정은 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 여부를 저장한다.
|
||||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||||
|
- 글 OG 이미지는 미디어 선택 또는 새 이미지 업로드로 설정하며, 공개 상세 화면의 `og:image`와 Twitter large image 카드에 사용한다.
|
||||||
|
- OG 이미지가 없으면 대표 이미지를 `og:image` fallback으로 사용한다.
|
||||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||||
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
||||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||||
@@ -366,7 +369,7 @@ components/content/
|
|||||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
||||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지, 게시물 OG 이미지, 본문 내 URL을 기준으로 표시한다.
|
||||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||||
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
## 3차 관리자 개발
|
## 3차 관리자 개발
|
||||||
|
|
||||||
- [ ] 초안 자동 저장
|
- [ ] 초안 자동 저장
|
||||||
- [ ] OG 이미지 설정
|
|
||||||
- [ ] 글 미리보기
|
- [ ] 글 미리보기
|
||||||
|
|
||||||
## 프론트엔드 개발
|
## 프론트엔드 개발
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.30
|
||||||
|
|
||||||
|
- 게시물 OG 이미지 필드 추가.
|
||||||
|
- 관리자 글 작성/수정 폼에 OG 이미지 선택, 업로드, 변경, 삭제 기능 추가.
|
||||||
|
- 공개 게시물 상세 화면에 OG 이미지와 Twitter large image 카드 메타 연결.
|
||||||
|
- 미디어 라이브러리 사용 현황에 게시물 OG 이미지 사용처 표시 추가.
|
||||||
|
- 패키지 버전을 0.0.30으로 갱신.
|
||||||
|
|
||||||
## v0.0.29
|
## v0.0.29
|
||||||
|
|
||||||
- 게시물 SEO 메타데이터 컬럼 추가.
|
- 게시물 SEO 메타데이터 컬럼 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.29",
|
"version": "0.0.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.29",
|
"version": "0.0.30",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.29",
|
"version": "0.0.30",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,6 +22,24 @@ const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
|
|||||||
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
|
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
|
||||||
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
|
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
|
||||||
const canonicalUrl = computed(() => post.value.canonicalUrl || pageUrl.value)
|
const canonicalUrl = computed(() => post.value.canonicalUrl || pageUrl.value)
|
||||||
|
const ogImage = computed(() => post.value.ogImage || post.value.featuredImage || '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 절대 URL 생성
|
||||||
|
* @param {string} value - 원본 URL
|
||||||
|
* @returns {string} 절대 URL
|
||||||
|
*/
|
||||||
|
const toAbsoluteUrl = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${siteUrl.value}${value.startsWith('/') ? value : `/${value}`}`
|
||||||
|
}
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: seoTitle.value,
|
title: seoTitle.value,
|
||||||
@@ -51,7 +69,19 @@ useHead(() => ({
|
|||||||
{
|
{
|
||||||
property: 'og:url',
|
property: 'og:url',
|
||||||
content: pageUrl.value
|
content: pageUrl.value
|
||||||
}
|
},
|
||||||
|
...(ogImage.value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
property: 'og:image',
|
||||||
|
content: toAbsoluteUrl(ogImage.value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'twitter:card',
|
||||||
|
content: 'summary_large_image'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const mapPostRow = (row) => ({
|
|||||||
seoDescription: row.seo_description || '',
|
seoDescription: row.seo_description || '',
|
||||||
canonicalUrl: row.canonical_url || '',
|
canonicalUrl: row.canonical_url || '',
|
||||||
noindex: Boolean(row.noindex),
|
noindex: Boolean(row.noindex),
|
||||||
|
ogImage: row.og_image || null,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
publishedAt: row.published_at ? row.published_at.toISOString() : null,
|
publishedAt: row.published_at ? row.published_at.toISOString() : null,
|
||||||
createdAt: row.created_at.toISOString(),
|
createdAt: row.created_at.toISOString(),
|
||||||
@@ -249,6 +250,7 @@ export const createAdminPost = async (input) => {
|
|||||||
seo_description,
|
seo_description,
|
||||||
canonical_url,
|
canonical_url,
|
||||||
noindex,
|
noindex,
|
||||||
|
og_image,
|
||||||
status,
|
status,
|
||||||
published_at
|
published_at
|
||||||
)
|
)
|
||||||
@@ -262,6 +264,7 @@ export const createAdminPost = async (input) => {
|
|||||||
${input.seoDescription},
|
${input.seoDescription},
|
||||||
${input.canonicalUrl},
|
${input.canonicalUrl},
|
||||||
${input.noindex},
|
${input.noindex},
|
||||||
|
${input.ogImage},
|
||||||
${input.status},
|
${input.status},
|
||||||
${input.publishedAt}
|
${input.publishedAt}
|
||||||
)
|
)
|
||||||
@@ -302,6 +305,7 @@ export const updateAdminPost = async (id, input) => {
|
|||||||
seo_description = ${input.seoDescription},
|
seo_description = ${input.seoDescription},
|
||||||
canonical_url = ${input.canonicalUrl},
|
canonical_url = ${input.canonicalUrl},
|
||||||
noindex = ${input.noindex},
|
noindex = ${input.noindex},
|
||||||
|
og_image = ${input.ogImage},
|
||||||
status = ${input.status},
|
status = ${input.status},
|
||||||
published_at = ${input.publishedAt},
|
published_at = ${input.publishedAt},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const adminPostInputSchema = z.object({
|
|||||||
seoDescription: z.string().trim().max(180).default(''),
|
seoDescription: z.string().trim().max(180).default(''),
|
||||||
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
|
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
|
||||||
noindex: z.boolean().default(false),
|
noindex: z.boolean().default(false),
|
||||||
|
ogImage: z.string().trim().nullable().default(null),
|
||||||
status: postStatusSchema.default('draft'),
|
status: postStatusSchema.default('draft'),
|
||||||
publishedAt: z.string().datetime().nullable().default(null),
|
publishedAt: z.string().datetime().nullable().default(null),
|
||||||
tags: z.array(z.string().trim().min(1)).default([])
|
tags: z.array(z.string().trim().min(1)).default([])
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const postSchema = z.object({
|
|||||||
seoDescription: z.string().default(''),
|
seoDescription: z.string().default(''),
|
||||||
canonicalUrl: z.string().default(''),
|
canonicalUrl: z.string().default(''),
|
||||||
noindex: z.boolean().default(false),
|
noindex: z.boolean().default(false),
|
||||||
|
ogImage: z.string().nullable().default(null),
|
||||||
status: postStatusSchema,
|
status: postStatusSchema,
|
||||||
publishedAt: z.string().nullable().default(null),
|
publishedAt: z.string().nullable().default(null),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ const getContentMediaUsage = (contentItem, url) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contentItem.ogImage === url) {
|
||||||
|
usages.push({
|
||||||
|
location: 'ogImage',
|
||||||
|
label: 'OG 이미지'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (contentItem.content?.includes(url)) {
|
if (contentItem.content?.includes(url)) {
|
||||||
usages.push({
|
usages.push({
|
||||||
location: 'content',
|
location: 'content',
|
||||||
|
|||||||
Reference in New Issue
Block a user