v1.4.6: 사이트 설정 이미지 저장 흐름·홈 커버 라이트/다크 분리

- 로고 업로드는 파일 URL만 폼에 반영하고 기타 설정 저장 시 DB에 반영
- 메인 화면 커버 라이트·다크 이미지 필드 추가 및 테마별 HomeHero 교체
- home_cover_dark_image_url 마이그레이션 및 미디어 사용 현황 보정
This commit is contained in:
2026-05-22 17:05:34 +09:00
parent 38ca3a4709
commit dcd1060ec7
15 changed files with 260 additions and 74 deletions

View File

@@ -19,10 +19,12 @@ const savingAnnouncement = ref(false)
const savingSpam = ref(false)
const uploadingLogo = ref(false)
const uploadingHomeCover = ref(false)
const uploadingHomeCoverDark = ref(false)
const errorMessage = ref('')
const toast = ref(null)
const logoInputRef = ref(null)
const homeCoverInputRef = ref(null)
const homeCoverDarkInputRef = ref(null)
const mainScrollRef = ref(null)
const navSearchQuery = ref('')
const activeSectionId = ref('admin-settings-section-title')
@@ -59,6 +61,7 @@ const postSnapshot = reactive({
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
const homeCoverSnapshot = reactive({
homeCoverImageUrl: '',
homeCoverDarkImageUrl: '',
homeCoverTitle: '',
homeCoverText: ''
})
@@ -88,6 +91,7 @@ const form = reactive({
copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
homeCoverTitle: settings.value?.homeCoverTitle || '',
homeCoverText: settings.value?.homeCoverText || '',
announcementEnabled: Boolean(settings.value?.announcementEnabled),
@@ -131,6 +135,7 @@ const hasPostChanges = computed(() => editPost.value
*/
const hasHomeCoverChanges = computed(() => editHomeCover.value && (
form.homeCoverImageUrl !== homeCoverSnapshot.homeCoverImageUrl
|| form.homeCoverDarkImageUrl !== homeCoverSnapshot.homeCoverDarkImageUrl
|| form.homeCoverTitle !== homeCoverSnapshot.homeCoverTitle
|| form.homeCoverText !== homeCoverSnapshot.homeCoverText
))
@@ -352,14 +357,13 @@ const uploadLogo = async (event) => {
try {
const formData = new FormData()
formData.append('file', file)
const updatedSettings = await $fetch('/admin/api/settings/logo', {
const uploadedLogo = await $fetch('/admin/api/settings/logo', {
method: 'POST',
body: formData
})
Object.assign(form, updatedSettings)
miscSnapshot.logoUrl = form.logoUrl
miscSnapshot.faviconUrl = form.faviconUrl
showToast('success', '로고가 등록되었습니다.')
form.logoUrl = uploadedLogo.logoUrl || ''
form.faviconUrl = uploadedLogo.faviconUrl || ''
showToast('success', '로고를 불러왔습니다. 저장 버튼을 눌러 적용하세요.')
} catch (error) {
errorMessage.value = error?.data?.message || '로고 업로드에 실패했습니다.'
showToast('error', errorMessage.value)
@@ -385,6 +389,7 @@ const buildSiteSettingsPayload = () => ({
copyrightText: form.copyrightText,
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
homeCoverText: form.homeCoverText || '',
announcementEnabled: Boolean(form.announcementEnabled),
@@ -562,19 +567,33 @@ const openHomeCoverFilePicker = () => {
}
/**
* 메인 화면 커버 이미지를 업로드한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
* 메인 화면 다크 커버 파일 선택 창을 연다.
* @returns {void}
*/
const uploadHomeCover = async (event) => {
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
const file = target?.files?.[0]
if (!file || uploadingHomeCover.value) {
const openHomeCoverDarkFilePicker = () => {
if (uploadingHomeCoverDark.value) {
return
}
uploadingHomeCover.value = true
homeCoverDarkInputRef.value?.click()
}
/**
* 메인 화면 커버 이미지를 업로드한다.
* @param {Event} event - 파일 선택 이벤트
* @param {'light'|'dark'} variant - 커버 이미지 종류
* @returns {Promise<void>}
*/
const uploadHomeCover = async (event, variant = 'light') => {
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
const file = target?.files?.[0]
const uploadingFlag = variant === 'dark' ? uploadingHomeCoverDark : uploadingHomeCover
if (!file || uploadingFlag.value) {
return
}
uploadingFlag.value = true
errorMessage.value = ''
showToast('info', '커버 이미지를 업로드하는 중입니다.')
@@ -585,13 +604,17 @@ const uploadHomeCover = async (event) => {
method: 'POST',
body: formData
})
form.homeCoverImageUrl = homeCoverImageUrl || ''
if (variant === 'dark') {
form.homeCoverDarkImageUrl = homeCoverImageUrl || ''
} else {
form.homeCoverImageUrl = homeCoverImageUrl || ''
}
showToast('success', '커버 이미지를 불러왔습니다. 저장 버튼을 눌러 적용하세요.')
} catch (error) {
errorMessage.value = error?.data?.message || '커버 이미지 업로드에 실패했습니다.'
showToast('error', errorMessage.value)
} finally {
uploadingHomeCover.value = false
uploadingFlag.value = false
if (target) {
target.value = ''
}
@@ -600,9 +623,15 @@ const uploadHomeCover = async (event) => {
/**
* 메인 화면 커버 이미지를 제거한다.
* @param {'light'|'dark'} variant - 커버 이미지 종류
* @returns {void}
*/
const clearHomeCoverImage = () => {
const clearHomeCoverImage = (variant = 'light') => {
if (variant === 'dark') {
form.homeCoverDarkImageUrl = ''
return
}
form.homeCoverImageUrl = ''
}
@@ -612,6 +641,7 @@ const clearHomeCoverImage = () => {
*/
const beginEditHomeCover = () => {
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
homeCoverSnapshot.homeCoverDarkImageUrl = form.homeCoverDarkImageUrl
homeCoverSnapshot.homeCoverTitle = form.homeCoverTitle
homeCoverSnapshot.homeCoverText = form.homeCoverText
editHomeCover.value = true
@@ -623,6 +653,7 @@ const beginEditHomeCover = () => {
*/
const cancelEditHomeCover = () => {
form.homeCoverImageUrl = homeCoverSnapshot.homeCoverImageUrl
form.homeCoverDarkImageUrl = homeCoverSnapshot.homeCoverDarkImageUrl
form.homeCoverTitle = homeCoverSnapshot.homeCoverTitle
form.homeCoverText = homeCoverSnapshot.homeCoverText
editHomeCover.value = false
@@ -644,6 +675,7 @@ const saveHomeCoverSection = async () => {
if (ok) {
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
homeCoverSnapshot.homeCoverDarkImageUrl = form.homeCoverDarkImageUrl
homeCoverSnapshot.homeCoverTitle = form.homeCoverTitle
homeCoverSnapshot.homeCoverText = form.homeCoverText
editHomeCover.value = false
@@ -1290,7 +1322,7 @@ onBeforeUnmount(() => {
v-if="!editHomeCover"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
상단에 720px 너비 커버 이미지를 표시합니다. 제목·짧은 문구는 이미지 왼쪽 하단에 겹쳐 보이며, 이미지·텍스트는 저장 버튼으로 함께 반영합니다.
상단에 720px 너비 커버 이미지를 표시합니다. 라이트·다크 이미지 각각 등록할 있고, 이미지·텍스트는 저장 버튼으로 함께 반영합니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
@@ -1328,9 +1360,10 @@ onBeforeUnmount(() => {
v-if="!editHomeCover"
class="admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<div v-if="form.homeCoverImageUrl" class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]">
<div v-if="form.homeCoverImageUrl || form.homeCoverDarkImageUrl" class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]">
<HomeHero
:image-url="form.homeCoverImageUrl"
:dark-image-url="form.homeCoverDarkImageUrl"
:title="form.homeCoverTitle"
:text="form.homeCoverText"
/>
@@ -1341,50 +1374,95 @@ onBeforeUnmount(() => {
</div>
<div v-else class="admin-settings-screen__home-cover-edit grid gap-6 border-t border-[#eceff2] pt-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-[#3f4650]">
커버 이미지
</h3>
<p class="mt-1 max-w-md text-sm leading-relaxed text-[#657080]">
가로 720px WebP로 변환해 미리 불러옵니다. 제목·본문과 함께 저장 버튼을 눌러야 사이트에 반영됩니다.
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<button
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
<div class="grid gap-4 md:grid-cols-2">
<div class="admin-settings-screen__home-cover-upload rounded-lg border border-[#e6e8eb] bg-[#fbfbfc] p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-[#3f4650]">
라이트모드 이미지
</h3>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
기본 헤더 이미지입니다. 다크 이미지가 없으면 다크모드에서도 이미지를 사용합니다.
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<button
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingHomeCover"
@click="openHomeCoverFilePicker"
>
{{ uploadingHomeCover ? '업로드 중' : form.homeCoverImageUrl ? '이미지 변경' : '이미지 등록' }}
</button>
<button
v-if="form.homeCoverImageUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingHomeCover"
@click="clearHomeCoverImage('light')"
>
제거
</button>
</div>
</div>
<input
ref="homeCoverInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingHomeCover"
@click="openHomeCoverFilePicker"
@change="uploadHomeCover($event, 'light')"
>
</div>
<div class="admin-settings-screen__home-cover-upload rounded-lg border border-[#e6e8eb] bg-[#fbfbfc] p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-[#3f4650]">
다크모드 이미지
</h3>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
다크모드에서만 교체되는 이미지입니다. 선택하지 않으면 라이트 이미지를 그대로 씁니다.
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<button
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingHomeCoverDark"
@click="openHomeCoverDarkFilePicker"
>
{{ uploadingHomeCoverDark ? '업로드 중' : form.homeCoverDarkImageUrl ? '이미지 변경' : '이미지 등록' }}
</button>
<button
v-if="form.homeCoverDarkImageUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingHomeCoverDark"
@click="clearHomeCoverImage('dark')"
>
제거
</button>
</div>
</div>
<input
ref="homeCoverDarkInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingHomeCoverDark"
@change="uploadHomeCover($event, 'dark')"
>
{{ uploadingHomeCover ? '업로드 중' : form.homeCoverImageUrl ? '이미지 변경' : '이미지 등록' }}
</button>
<button
v-if="form.homeCoverImageUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingHomeCover"
@click="clearHomeCoverImage"
>
이미지 제거
</button>
</div>
<input
ref="homeCoverInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingHomeCover"
@change="uploadHomeCover"
>
</div>
<div
v-if="form.homeCoverImageUrl"
v-if="form.homeCoverImageUrl || form.homeCoverDarkImageUrl"
class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]"
>
<HomeHero
:image-url="form.homeCoverImageUrl"
:dark-image-url="form.homeCoverDarkImageUrl"
:title="form.homeCoverTitle"
:text="form.homeCoverText"
/>