게시물 OG 이미지 설정 추가
This commit is contained in:
@@ -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>
|
||||
|
||||
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
|
||||
|
||||
### 게시물 SEO 설정 범위 결정
|
||||
|
||||
@@ -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 이미지 필드 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
@@ -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로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||
@@ -366,7 +369,7 @@ components/content/
|
||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지, 게시물 OG 이미지, 본문 내 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
## 3차 관리자 개발
|
||||
|
||||
- [ ] 초안 자동 저장
|
||||
- [ ] OG 이미지 설정
|
||||
- [ ] 글 미리보기
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.29",
|
||||
"version": "0.0.30",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user