태그 헤더 이미지 설정 추가
This commit is contained in:
@@ -25,23 +25,32 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const slugTouched = ref(Boolean(props.initialTag.slug))
|
||||
const coverInputRef = ref(null)
|
||||
const uploadingCover = ref(false)
|
||||
const coverErrorMessage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || '',
|
||||
description: props.initialTag.description || '',
|
||||
headerImageUrl: props.initialTag.headerImageUrl || '',
|
||||
headerTitle: props.initialTag.headerTitle || '',
|
||||
headerText: props.initialTag.headerText || '',
|
||||
color: props.initialTag.color || '#15171a'
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 입력값을 저장 비교용 형태로 정규화한다.
|
||||
* @param {Object} tag - 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
|
||||
*/
|
||||
const normalizeTagPayload = (tag) => ({
|
||||
name: String(tag.name || '').trim(),
|
||||
slug: toSlug(tag.slug || tag.name || ''),
|
||||
description: String(tag.description || '').trim(),
|
||||
headerImageUrl: String(tag.headerImageUrl || '').trim(),
|
||||
headerTitle: String(tag.headerTitle || '').trim(),
|
||||
headerText: String(tag.headerText || '').trim(),
|
||||
sortOrder: Number(tag.sortOrder ?? 0),
|
||||
color: String(tag.color || '#15171a'),
|
||||
tagType: String(tag.tagType || props.defaultTagType)
|
||||
@@ -49,12 +58,15 @@ const normalizeTagPayload = (tag) => ({
|
||||
|
||||
/**
|
||||
* 현재 폼 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
|
||||
*/
|
||||
const currentPayload = computed(() => normalizeTagPayload({
|
||||
name: form.name,
|
||||
slug: form.slug || form.name,
|
||||
description: form.description,
|
||||
headerImageUrl: form.headerImageUrl,
|
||||
headerTitle: form.headerTitle,
|
||||
headerText: form.headerText,
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
@@ -62,12 +74,15 @@ const currentPayload = computed(() => normalizeTagPayload({
|
||||
|
||||
/**
|
||||
* 최초 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
|
||||
*/
|
||||
const initialPayload = computed(() => normalizeTagPayload({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || props.initialTag.name || '',
|
||||
description: props.initialTag.description || '',
|
||||
headerImageUrl: props.initialTag.headerImageUrl || '',
|
||||
headerTitle: props.initialTag.headerTitle || '',
|
||||
headerText: props.initialTag.headerText || '',
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: props.initialTag.color || '#15171a',
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
@@ -83,7 +98,7 @@ const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.
|
||||
* 태그 저장 가능 여부
|
||||
* @returns {boolean} 저장 가능 여부
|
||||
*/
|
||||
const canSubmit = computed(() => !props.saving && (!props.requireChanges || hasChanges.value))
|
||||
const canSubmit = computed(() => !props.saving && !uploadingCover.value && (!props.requireChanges || hasChanges.value))
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
@@ -113,6 +128,56 @@ const touchSlug = () => {
|
||||
form.slug = toSlug(form.slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지 선택 창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openCoverPicker = () => {
|
||||
coverInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지를 제거한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeCoverImage = () => {
|
||||
form.headerImageUrl = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지를 업로드한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>} 업로드 결과
|
||||
*/
|
||||
const uploadCoverImage = async (event) => {
|
||||
const input = event.target
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingCover.value = true
|
||||
coverErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const result = await $fetch('/admin/api/tags/cover', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
form.headerImageUrl = result.headerImageUrl || ''
|
||||
} catch (error) {
|
||||
coverErrorMessage.value = error?.data?.message || '태그 헤더 이미지를 업로드하지 못했습니다.'
|
||||
} finally {
|
||||
uploadingCover.value = false
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 입력값 제출
|
||||
* @returns {void}
|
||||
@@ -178,6 +243,89 @@ const submitTag = () => {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="admin-tag-form__panel admin-tag-form__panel--header grid gap-5 border border-line bg-white p-5">
|
||||
<div class="admin-tag-form__header-cover-head flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="admin-tag-form__header-cover-copy grid gap-1">
|
||||
<h2 class="admin-tag-form__section-title text-base font-semibold">
|
||||
태그 헤더
|
||||
</h2>
|
||||
<p class="admin-tag-form__section-description text-sm text-muted">
|
||||
이미지가 있으면 공개 태그 페이지의 기본 제목과 설명 대신 헤더 이미지를 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-tag-form__header-cover-actions flex shrink-0 gap-2">
|
||||
<button
|
||||
class="admin-tag-form__cover-button rounded border border-line bg-white px-3 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="openCoverPicker"
|
||||
>
|
||||
{{ uploadingCover ? '업로드 중' : '이미지 변경' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.headerImageUrl"
|
||||
class="admin-tag-form__cover-remove rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-muted disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="removeCoverImage"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.headerImageUrl"
|
||||
class="admin-tag-form__cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||
>
|
||||
<HomeHero
|
||||
:image-url="form.headerImageUrl"
|
||||
:title="form.headerTitle"
|
||||
:text="form.headerText"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="admin-tag-form__cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center text-sm text-muted transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="openCoverPicker"
|
||||
>
|
||||
태그 헤더 이미지를 선택하세요.
|
||||
</button>
|
||||
<input
|
||||
ref="coverInputRef"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
@change="uploadCoverImage"
|
||||
>
|
||||
<p v-if="coverErrorMessage" class="admin-tag-form__cover-error rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{{ coverErrorMessage }}
|
||||
</p>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">오버레이 제목</span>
|
||||
<input
|
||||
v-model="form.headerTitle"
|
||||
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="비워두면 이미지에 제목을 표시하지 않습니다."
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">오버레이 본문</span>
|
||||
<textarea
|
||||
v-model="form.headerText"
|
||||
class="admin-tag-form__textarea min-h-24 rounded border border-line bg-white px-3 py-2"
|
||||
maxlength="280"
|
||||
placeholder="비워두면 이미지에 본문을 표시하지 않습니다."
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="admin-tag-form__actions flex justify-end gap-3">
|
||||
<NuxtLink class="admin-tag-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/tags">
|
||||
취소
|
||||
|
||||
Reference in New Issue
Block a user