태그 헤더 이미지 설정 추가

This commit is contained in:
2026-06-26 18:08:05 +09:00
parent 6d74425415
commit 67ea58ccc2
16 changed files with 340 additions and 25 deletions

View File

@@ -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">
취소