태그 헤더 이미지 설정 추가

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

View File

@@ -32,6 +32,9 @@ CREATE TABLE IF NOT EXISTS tags (
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
header_image_url TEXT NOT NULL DEFAULT '',
header_title TEXT NOT NULL DEFAULT '',
header_text TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#15171a',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

View File

@@ -0,0 +1,4 @@
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS header_image_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS header_title TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS header_text TEXT NOT NULL DEFAULT '';

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.8.7
- 태그마다 헤더 이미지를 설정할 수 있게 했다.
- 태그 헤더 이미지를 쓰면 태그 페이지 상단의 기본 제목/설명 대신 이미지를 표시하고, 필요하면 이미지 위 오버레이 제목과 본문을 따로 넣을 수 있다.
## v1.8.6
- 일반 태그 페이지에서도 슬러그 대신 등록된 태그 이름과 설명이 보이도록 수정했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.8.6에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.8.7에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,13 @@
## 로컬 개발
### v1.8.7 참고
- DB 마이그레이션 `058_tag_header_cover.sql`이 추가되었다.
- 배포 전 운영 DB에 `npm run db:migrate:prod`를 실행한다.
- 관리자 태그 생성·수정 화면에서 태그 헤더 이미지를 업로드하고 저장한 뒤 `/tag/:slug`에서 기본 제목/설명 대신 이미지가 표시되는지 확인한다.
- 오버레이 제목·본문을 비워두면 태그 헤더 이미지 단독으로 표시되는지 확인한다.
### v1.8.6 참고
- 추가 DB 마이그레이션은 없다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-26 v1.8.7 — 태그 헤더는 이미지 우선 옵션으로 둔다
태그 페이지는 기본적으로 태그 이름과 설명을 보여 주지만, 특정 태그는 시리즈나 작품 단위로 별도 분위기를 줄 필요가 있다. 태그 헤더 이미지가 설정된 경우에는 기본 제목·설명을 자동으로 숨기고 이미지를 우선 표시한다. 오버레이 제목·본문은 선택 입력으로 두어, 이미지만 쓰는 태그와 이미지 위 텍스트를 쓰는 태그를 같은 구조에서 처리한다.
## 2026-06-26 v1.8.6 — 공개 태그 메타 조회 범위를 분리한다
왼쪽 사이드바와 `/tags` 목록은 기존처럼 메인 태그만 노출해야 하지만, `/tag/:slug` 상세와 게시물 배지는 일반 태그의 이름·설명·색상도 필요하다. 따라서 공개 태그 API의 기본값은 메인 태그로 유지하고, `tagType=all` 옵션을 추가해 필요한 화면에서만 전체 태그 메타를 조회하도록 정리했다.

View File

@@ -66,6 +66,7 @@
|------|------|
| db/migrations/056_site_settings_post_tag_limit.sql | 사이트 설정의 게시물 태그 최대 개수 컬럼 추가 |
| db/migrations/057_navigation_recommended_favicon_cache.sql | 추천 사이트 파비콘 캐시 URL·갱신 시각 컬럼 추가 |
| db/migrations/058_tag_header_cover.sql | 태그 헤더 이미지·오버레이 제목·본문 컬럼 추가 |
## 서버 미들웨어
@@ -118,7 +119,7 @@
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
@@ -195,7 +196,7 @@
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
| pages/tag/[slug].vue | 태그별 글 목록, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 해당 URL 재진입 |
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
@@ -211,6 +212,7 @@
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만) |
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
| server/api/tags.get.js | 공개 태그 목록 API, 기본 메인 태그 반환·`tagType=all` 전체 태그 메타 반환 |
| server/routes/admin/api/tags/cover.post.js | 관리자 태그 헤더 이미지 업로드 API(`/uploads/system/tag-cover-*.webp`) |
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
| server/api/homepage-widget.get.js | gethomepage customapi용 당일·7일·30일·180일·365일·누적·콘텐츠 통계 위젯 API |

View File

@@ -127,6 +127,7 @@
- `/tag/:slug` - 태그별 게시물 목록
- 태그명에 `/`를 입력하면 태그명은 그대로 저장하고 슬러그에서는 `/`를 `-`로 변환한다. 예: `Xenoblade3/Hero` 태그명은 `xenoblade3-hero` 슬러그로 저장한다.
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
- 태그에 `headerImageUrl`이 있으면 `/tag/:slug` 상단에는 기본 태그명·설명 대신 태그 헤더 이미지를 표시한다. `headerTitle`, `headerText`는 이미지 위 오버레이로 표시하며, 둘 다 비어 있으면 이미지 단독으로 표시한다.
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
- `/signin` - 로그인
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
@@ -642,6 +643,7 @@ components/content/
- 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제, 태그 생성·수정 저장 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다. 태그 수정 화면의 `변경 저장` 버튼은 실제 변경사항이 있을 때만 활성화한다.
- 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
- 태그 헤더는 `headerImageUrl`, `headerTitle`, `headerText`를 사용한다. 관리자 태그 폼에서 이미지를 업로드하면 `/uploads/system/tag-cover-*.webp`로 저장하고, 해당 이미지가 미디어 사용처로 보호된다.
### 관리자 글 편집

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.8.7
- 태그별 헤더 이미지·오버레이 제목·오버레이 본문 필드 추가.
- 관리자 태그 생성·수정 폼에 태그 헤더 이미지 업로드·삭제와 오버레이 입력 추가.
- 공개 태그 상세에서 헤더 이미지가 있으면 기본 태그명·설명 대신 헤더 이미지를 표시하도록 수정.
- 태그 헤더 이미지가 미디어 사용처로 보호되도록 정리.
## v1.8.6
- 공개 태그 API에 전체 태그 메타 조회 옵션을 추가.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.8.6",
"version": "1.8.7",
"private": true,
"type": "module",
"imports": {

View File

@@ -11,6 +11,7 @@ const { data: posts } = await useFetch('/api/posts', {
})
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
const hasTagHeaderImage = computed(() => Boolean(tag.value?.headerImageUrl))
/**
* 태그 슬러그로 공개 태그 메타를 찾는다.
@@ -48,7 +49,13 @@ const tagPosts = computed(() => posts.value
<MainColumn>
<section class="tag-posts-header site-section">
<div class="tag-posts-header__inner site-section-header mx-auto flex max-w-[720px] flex-col gap-4">
<div class="flex flex-col gap-1">
<HomeHero
v-if="hasTagHeaderImage"
:image-url="tag.headerImageUrl"
:title="tag.headerTitle"
:text="tag.headerText"
/>
<div v-else class="flex flex-col gap-1">
<h1 class="text-lg font-medium leading-tight sm:text-xl">
{{ tag?.name || slug.toUpperCase() }}
</h1>

View File

@@ -94,6 +94,9 @@ const mapTagRow = (row) => ({
name: row.name,
slug: row.slug,
description: row.description,
headerImageUrl: row.header_image_url || '',
headerTitle: row.header_title || '',
headerText: row.header_text || '',
sortOrder: row.sort_order,
color: row.color,
tagType: row.tag_type || 'managed',
@@ -743,6 +746,9 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
if (!sql) {
const sampleTags = getSampleTags().map((tag) => ({
...tag,
headerImageUrl: tag.headerImageUrl || '',
headerTitle: tag.headerTitle || '',
headerText: tag.headerText || '',
tagType: 'managed',
postCount: 0,
lastUsedAt: null,
@@ -1327,8 +1333,8 @@ export const createAdminTag = async (input) => {
}
const rows = await sql`
INSERT INTO tags (name, slug, description, sort_order, color, tag_type)
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color}, ${input.tagType})
INSERT INTO tags (name, slug, description, header_image_url, header_title, header_text, sort_order, color, tag_type)
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.headerImageUrl}, ${input.headerTitle}, ${input.headerText}, ${input.sortOrder}, ${input.color}, ${input.tagType})
RETURNING *
`
@@ -1354,6 +1360,9 @@ export const updateAdminTag = async (id, input) => {
name = ${input.name},
slug = ${input.slug},
description = ${input.description},
header_image_url = ${input.headerImageUrl},
header_title = ${input.headerTitle},
header_text = ${input.headerText},
sort_order = ${input.sortOrder},
color = ${input.color},
tag_type = ${input.tagType},

View File

@@ -0,0 +1,88 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
const tagCoverWidth = 720
/**
* 시스템 자산 파일명 접미사
* @returns {string} 파일명 접미사
*/
const createSystemAssetSuffix = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
return `${year}${month}-${Math.random().toString(36).slice(2, 8)}`
}
/**
* 태그 헤더 커버 이미지 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ headerImageUrl: string }>} 업로드된 이미지 URL
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: '업로드할 태그 헤더 이미지가 없습니다.'
})
}
if (!allowedImageTypes.has(file.type)) {
throw createError({
statusCode: 400,
message: 'JPG, PNG, WebP 이미지만 태그 헤더로 사용할 수 있습니다.'
})
}
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: '업로드 가능한 파일 크기를 초과했습니다.'
})
}
const metadata = await sharp(file.data).metadata()
if (!metadata.width || !metadata.height) {
throw createError({
statusCode: 400,
message: '이미지 메타데이터를 읽을 수 없습니다.'
})
}
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const assetSuffix = createSystemAssetSuffix()
const coverFileName = `tag-cover-${assetSuffix}.webp`
const coverPath = join(directoryPath, coverFileName)
const headerImageUrl = `${uploadBaseUrl}/system/${coverFileName}`
await mkdir(directoryPath, { recursive: true })
const coverBuffer = await sharp(file.data)
.rotate()
.resize({
width: tagCoverWidth,
withoutEnlargement: true
})
.webp({ quality: 88 })
.toBuffer()
await writeFile(coverPath, coverBuffer)
await upsertMediaMetadataCategory(headerImageUrl, '시스템')
return { headerImageUrl }
})

View File

@@ -4,6 +4,9 @@ export const adminTagInputSchema = z.object({
name: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
description: z.string().default(''),
headerImageUrl: z.string().trim().max(500).optional().default(''),
headerTitle: z.string().trim().max(120).optional().default(''),
headerText: z.string().trim().max(280).optional().default(''),
sortOrder: z.number().int().min(0).default(0),
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a'),
tagType: z.enum(['managed', 'general']).default('managed')

View File

@@ -1,7 +1,7 @@
import { readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path'
import { createError } from 'h3'
import { getSiteSettings, listAdminPosts, listPages } from '../repositories/content-repository'
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
import { getPostgresClient } from '../repositories/postgres-client'
import {
getPostThumbnailUrl,
@@ -506,17 +506,40 @@ const getMediaUsage = (url, posts, pages) => {
return [...postUsages, ...pageUsages]
}
/**
* 태그 헤더 이미지 사용처 조회
* @param {string} url - 미디어 URL
* @param {Array<Object>} tags - 태그 목록
* @returns {Array<Object>} 사용처 목록
*/
const getTagMediaUsage = (url, tags) => tags
.filter((tag) => tag.headerImageUrl === url)
.map((tag) => ({
type: 'tag',
typeLabel: '태그',
id: tag.id,
title: tag.name,
slug: tag.slug,
adminUrl: `/admin/tags/${tag.id}`,
publicUrl: `/tag/${tag.slug}`,
status: tag.tagType,
location: 'headerImageUrl',
label: '태그 헤더 이미지'
}))
/**
* 미디어 URL이 실제 사용처에서 쓰이는지 조회한다.
* @param {string} url - 미디어 URL
* @param {Array<Object>} posts - 게시물 목록
* @param {Array<Object>} pages - 페이지 목록
* @param {Object} siteSettings - 사이트 설정
* @param {Array<Object>} tags - 태그 목록
* @returns {Array<Object>} 사용처 목록
*/
const getDirectMediaUsage = (url, posts, pages, siteSettings) => [
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
...getMediaUsage(url, posts, pages),
...getSiteSettingsMediaUsage(url, siteSettings)
...getSiteSettingsMediaUsage(url, siteSettings),
...getTagMediaUsage(url, tags)
]
/**
@@ -673,10 +696,11 @@ const getSiteSettingsMediaUsage = (url, siteSettings) => {
export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap()
const [posts, pages, siteSettings] = await Promise.all([
const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(),
listPages(),
getSiteSettings()
getSiteSettings(),
listAdminTags()
])
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
const itemUrlSet = new Set(items.map((item) => item.url))
@@ -685,7 +709,7 @@ export const listMediaItems = async () => {
const rawCategory = metadataMap[item.url]?.category ?? item.category
const category = normalizeStoredDisplayCategory(item.url, rawCategory)
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings)
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings, tags)
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
@@ -818,13 +842,14 @@ export const updateMediaCategories = async (urls, category) => {
* @returns {Promise<void>}
*/
export const deleteMediaItem = async (url) => {
const [posts, pages, siteSettings] = await Promise.all([
const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(),
listPages(),
getSiteSettings()
getSiteSettings(),
listAdminTags()
])
const usage = [
...getDirectMediaUsage(url, posts, pages, siteSettings),
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
]
@@ -860,13 +885,14 @@ export const renameMediaItem = async (url, name) => {
})
}
const [posts, pages, siteSettings] = await Promise.all([
const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(),
listPages(),
getSiteSettings()
getSiteSettings(),
listAdminTags()
])
const usage = [
...getDirectMediaUsage(url, posts, pages, siteSettings),
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
]