게시물 OG 이미지 설정 추가

This commit is contained in:
2026-05-03 10:10:09 +09:00
parent fc5f41b9cc
commit 8c5ccc94ec
14 changed files with 157 additions and 14 deletions

View File

@@ -21,8 +21,10 @@ const slugTouched = ref(Boolean(props.initialPost.slug))
const blockEditor = ref(null)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const mediaPickerTarget = ref('featuredImage')
const isLoadingMedia = ref(false)
const isUploadingFeaturedImage = ref(false)
const isUploadingOgImage = ref(false)
const autosaveTimer = ref(null)
const autosaveNotice = ref(null)
const autosaveStatus = ref('')
@@ -78,6 +80,7 @@ const form = reactive({
seoDescription: props.initialPost.seoDescription || '',
canonicalUrl: props.initialPost.canonicalUrl || '',
noindex: Boolean(props.initialPost.noindex),
ogImage: props.initialPost.ogImage || '',
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || ''
@@ -152,6 +155,7 @@ const createPostPayload = () => {
seoDescription: form.seoDescription.trim(),
canonicalUrl: form.canonicalUrl.trim(),
noindex: form.noindex,
ogImage: form.ogImage.trim() || null,
status: form.status,
publishedAt,
tags: parseTags(form.tagsText)
@@ -172,6 +176,7 @@ const createAutosavePayload = () => ({
seoDescription: form.seoDescription,
canonicalUrl: form.canonicalUrl,
noindex: form.noindex,
ogImage: form.ogImage,
status: form.status,
publishedAt: form.publishedAt,
tagsText: form.tagsText
@@ -188,6 +193,10 @@ const isEmptyAutosavePayload = (payload) => ![
payload.excerpt,
payload.content,
payload.featuredImage,
payload.seoTitle,
payload.seoDescription,
payload.canonicalUrl,
payload.ogImage,
payload.tagsText
].some((value) => String(value || '').trim())
@@ -293,7 +302,8 @@ const fetchMediaItems = async () => {
* 대표 이미지 선택 창 열기
* @returns {Promise<void>}
*/
const openMediaPicker = async () => {
const openMediaPicker = async (target = 'featuredImage') => {
mediaPickerTarget.value = target
isMediaPickerOpen.value = true
await fetchMediaItems()
}
@@ -311,8 +321,8 @@ const closeMediaPicker = () => {
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const selectFeaturedImage = (item) => {
form.featuredImage = item.url
const selectPickedImage = (item) => {
form[mediaPickerTarget.value] = item.url
closeMediaPicker()
}
@@ -324,6 +334,14 @@ const removeFeaturedImage = () => {
form.featuredImage = ''
}
/**
* OG 이미지 삭제
* @returns {void}
*/
const removeOgImage = () => {
form.ogImage = ''
}
/**
* 대표 이미지 파일 업로드
* @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}
@@ -557,7 +603,7 @@ defineExpose({
{{ form.featuredImage }}
</p>
<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>
<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>
</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>
</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">
<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">
대표 이미지 선택
{{ mediaPickerTarget === 'ogImage' ? 'OG 이미지 선택' : '대표 이미지 선택' }}
</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">
닫기
@@ -625,7 +704,7 @@ defineExpose({
:key="item.url"
class="admin-post-form__media-picker-item overflow-hidden border border-line bg-white text-left"
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">
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>

View File

@@ -0,0 +1,2 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS og_image TEXT;

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-03 v0.0.30
### OG 이미지 저장 방식 결정
게시물 OG 이미지는 대표 이미지와 별도 필드인 `og_image`로 저장한다. 대표 이미지는 화면 카드와 본문 진입 시각 요소에 쓰이고, OG 이미지는 외부 공유 미리보기 비율과 목적이 다를 수 있기 때문이다.
관리자 입력은 대표 이미지와 같은 미디어 선택/업로드 흐름을 재사용한다. OG 이미지가 비어 있으면 공개 상세 화면에서는 대표 이미지를 fallback으로 사용해 기존 글도 기본 공유 이미지를 가질 수 있게 한다.
## 2026-05-03 v0.0.29
### 게시물 SEO 설정 범위 결정

View File

@@ -26,7 +26,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지/OG 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
@@ -77,7 +77,7 @@
| pages/index.vue | 홈 |
| pages/posts/index.vue | 게시물 전체 목록 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO 메타 출력 |
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력 |
| pages/tags/index.vue | 태그 전체 목록 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록 |
@@ -148,6 +148,7 @@
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
## 설정/배포

View File

@@ -128,6 +128,7 @@ components/content/
| seo_description | String | SEO 설명 |
| canonical_url | String | canonical URL |
| noindex | Boolean | 검색엔진 노출 제외 여부 |
| og_image | String nullable | OG 이미지 |
| status | Enum | published/draft/private |
| published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 |
@@ -302,6 +303,8 @@ components/content/
- 글 SEO 설정은 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 여부를 저장한다.
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
- 글 OG 이미지는 미디어 선택 또는 새 이미지 업로드로 설정하며, 공개 상세 화면의 `og:image`와 Twitter large image 카드에 사용한다.
- OG 이미지가 없으면 대표 이미지를 `og:image` fallback으로 사용한다.
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![alt](url){width=wide}` 형식으로 저장한다.
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
@@ -366,7 +369,7 @@ components/content/
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
- 미디어 사용 현황은 게시물/페이지의 대표 이미지 본문 내 URL을 기준으로 표시한다.
- 미디어 사용 현황은 게시물/페이지의 대표 이미지, 게시물 OG 이미지, 본문 내 URL을 기준으로 표시한다.
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.

View File

@@ -22,7 +22,6 @@
## 3차 관리자 개발
- [ ] 초안 자동 저장
- [ ] OG 이미지 설정
- [ ] 글 미리보기
## 프론트엔드 개발

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## v0.0.30
- 게시물 OG 이미지 필드 추가.
- 관리자 글 작성/수정 폼에 OG 이미지 선택, 업로드, 변경, 삭제 기능 추가.
- 공개 게시물 상세 화면에 OG 이미지와 Twitter large image 카드 메타 연결.
- 미디어 라이브러리 사용 현황에 게시물 OG 이미지 사용처 표시 추가.
- 패키지 버전을 0.0.30으로 갱신.
## v0.0.29
- 게시물 SEO 메타데이터 컬럼 추가.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.29",
"version": "0.0.30",
"private": true,
"type": "module",
"scripts": {

View File

@@ -22,6 +22,24 @@ const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
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(() => ({
title: seoTitle.value,
@@ -51,7 +69,19 @@ useHead(() => ({
{
property: 'og:url',
content: pageUrl.value
}
},
...(ogImage.value
? [
{
property: 'og:image',
content: toAbsoluteUrl(ogImage.value)
},
{
name: 'twitter:card',
content: 'summary_large_image'
}
]
: [])
]
}))
</script>

View File

@@ -25,6 +25,7 @@ const mapPostRow = (row) => ({
seoDescription: row.seo_description || '',
canonicalUrl: row.canonical_url || '',
noindex: Boolean(row.noindex),
ogImage: row.og_image || null,
status: row.status,
publishedAt: row.published_at ? row.published_at.toISOString() : null,
createdAt: row.created_at.toISOString(),
@@ -249,6 +250,7 @@ export const createAdminPost = async (input) => {
seo_description,
canonical_url,
noindex,
og_image,
status,
published_at
)
@@ -262,6 +264,7 @@ export const createAdminPost = async (input) => {
${input.seoDescription},
${input.canonicalUrl},
${input.noindex},
${input.ogImage},
${input.status},
${input.publishedAt}
)
@@ -302,6 +305,7 @@ export const updateAdminPost = async (id, input) => {
seo_description = ${input.seoDescription},
canonical_url = ${input.canonicalUrl},
noindex = ${input.noindex},
og_image = ${input.ogImage},
status = ${input.status},
published_at = ${input.publishedAt},
updated_at = now()

View File

@@ -11,6 +11,7 @@ export const adminPostInputSchema = z.object({
seoDescription: z.string().trim().max(180).default(''),
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
noindex: z.boolean().default(false),
ogImage: z.string().trim().nullable().default(null),
status: postStatusSchema.default('draft'),
publishedAt: z.string().datetime().nullable().default(null),
tags: z.array(z.string().trim().min(1)).default([])

View File

@@ -13,6 +13,7 @@ export const postSchema = z.object({
seoDescription: z.string().default(''),
canonicalUrl: z.string().default(''),
noindex: z.boolean().default(false),
ogImage: z.string().nullable().default(null),
status: postStatusSchema,
publishedAt: z.string().nullable().default(null),
createdAt: z.string(),

View File

@@ -203,6 +203,13 @@ const getContentMediaUsage = (contentItem, url) => {
})
}
if (contentItem.ogImage === url) {
usages.push({
location: 'ogImage',
label: 'OG 이미지'
})
}
if (contentItem.content?.includes(url)) {
usages.push({
location: 'content',