태그 헤더 이미지 설정 추가

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 emit = defineEmits(['submit'])
const slugTouched = ref(Boolean(props.initialTag.slug)) const slugTouched = ref(Boolean(props.initialTag.slug))
const coverInputRef = ref(null)
const uploadingCover = ref(false)
const coverErrorMessage = ref('')
const form = reactive({ const form = reactive({
name: props.initialTag.name || '', name: props.initialTag.name || '',
slug: props.initialTag.slug || '', slug: props.initialTag.slug || '',
description: props.initialTag.description || '', description: props.initialTag.description || '',
headerImageUrl: props.initialTag.headerImageUrl || '',
headerTitle: props.initialTag.headerTitle || '',
headerText: props.initialTag.headerText || '',
color: props.initialTag.color || '#15171a' color: props.initialTag.color || '#15171a'
}) })
/** /**
* 태그 입력값을 저장 비교용 형태로 정규화한다. * 태그 입력값을 저장 비교용 형태로 정규화한다.
* @param {Object} tag - 태그 입력값 * @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) => ({ const normalizeTagPayload = (tag) => ({
name: String(tag.name || '').trim(), name: String(tag.name || '').trim(),
slug: toSlug(tag.slug || tag.name || ''), slug: toSlug(tag.slug || tag.name || ''),
description: String(tag.description || '').trim(), 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), sortOrder: Number(tag.sortOrder ?? 0),
color: String(tag.color || '#15171a'), color: String(tag.color || '#15171a'),
tagType: String(tag.tagType || props.defaultTagType) 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({ const currentPayload = computed(() => normalizeTagPayload({
name: form.name, name: form.name,
slug: form.slug || form.name, slug: form.slug || form.name,
description: form.description, description: form.description,
headerImageUrl: form.headerImageUrl,
headerTitle: form.headerTitle,
headerText: form.headerText,
sortOrder: props.initialTag.sortOrder ?? 0, sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color, color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType 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({ const initialPayload = computed(() => normalizeTagPayload({
name: props.initialTag.name || '', name: props.initialTag.name || '',
slug: props.initialTag.slug || props.initialTag.name || '', slug: props.initialTag.slug || props.initialTag.name || '',
description: props.initialTag.description || '', description: props.initialTag.description || '',
headerImageUrl: props.initialTag.headerImageUrl || '',
headerTitle: props.initialTag.headerTitle || '',
headerText: props.initialTag.headerText || '',
sortOrder: props.initialTag.sortOrder ?? 0, sortOrder: props.initialTag.sortOrder ?? 0,
color: props.initialTag.color || '#15171a', color: props.initialTag.color || '#15171a',
tagType: props.initialTag.tagType || props.defaultTagType tagType: props.initialTag.tagType || props.defaultTagType
@@ -83,7 +98,7 @@ const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.
* 태그 저장 가능 여부 * 태그 저장 가능 여부
* @returns {boolean} 저장 가능 여부 * @returns {boolean} 저장 가능 여부
*/ */
const canSubmit = computed(() => !props.saving && (!props.requireChanges || hasChanges.value)) const canSubmit = computed(() => !props.saving && !uploadingCover.value && (!props.requireChanges || hasChanges.value))
/** /**
* 문자열을 URL 슬러그로 변환 * 문자열을 URL 슬러그로 변환
@@ -113,6 +128,56 @@ const touchSlug = () => {
form.slug = toSlug(form.slug) 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} * @returns {void}
@@ -178,6 +243,89 @@ const submitTag = () => {
</label> </label>
</section> </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"> <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"> <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, name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '', 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, sort_order INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#15171a', color TEXT NOT NULL DEFAULT '#15171a',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 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 ## 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 참고 ### v1.8.6 참고
- 추가 DB 마이그레이션은 없다. - 추가 DB 마이그레이션은 없다.

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.8.6", "version": "1.8.7",
"private": true, "private": true,
"type": "module", "type": "module",
"imports": { "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 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> <MainColumn>
<section class="tag-posts-header site-section"> <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="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"> <h1 class="text-lg font-medium leading-tight sm:text-xl">
{{ tag?.name || slug.toUpperCase() }} {{ tag?.name || slug.toUpperCase() }}
</h1> </h1>

View File

@@ -94,6 +94,9 @@ const mapTagRow = (row) => ({
name: row.name, name: row.name,
slug: row.slug, slug: row.slug,
description: row.description, description: row.description,
headerImageUrl: row.header_image_url || '',
headerTitle: row.header_title || '',
headerText: row.header_text || '',
sortOrder: row.sort_order, sortOrder: row.sort_order,
color: row.color, color: row.color,
tagType: row.tag_type || 'managed', tagType: row.tag_type || 'managed',
@@ -743,6 +746,9 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
if (!sql) { if (!sql) {
const sampleTags = getSampleTags().map((tag) => ({ const sampleTags = getSampleTags().map((tag) => ({
...tag, ...tag,
headerImageUrl: tag.headerImageUrl || '',
headerTitle: tag.headerTitle || '',
headerText: tag.headerText || '',
tagType: 'managed', tagType: 'managed',
postCount: 0, postCount: 0,
lastUsedAt: null, lastUsedAt: null,
@@ -1327,8 +1333,8 @@ export const createAdminTag = async (input) => {
} }
const rows = await sql` const rows = await sql`
INSERT INTO tags (name, slug, description, sort_order, color, tag_type) 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.sortOrder}, ${input.color}, ${input.tagType}) VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.headerImageUrl}, ${input.headerTitle}, ${input.headerText}, ${input.sortOrder}, ${input.color}, ${input.tagType})
RETURNING * RETURNING *
` `
@@ -1354,6 +1360,9 @@ export const updateAdminTag = async (id, input) => {
name = ${input.name}, name = ${input.name},
slug = ${input.slug}, slug = ${input.slug},
description = ${input.description}, description = ${input.description},
header_image_url = ${input.headerImageUrl},
header_title = ${input.headerTitle},
header_text = ${input.headerText},
sort_order = ${input.sortOrder}, sort_order = ${input.sortOrder},
color = ${input.color}, color = ${input.color},
tag_type = ${input.tagType}, 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), name: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/), slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
description: z.string().default(''), 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), sortOrder: z.number().int().min(0).default(0),
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a'), color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a'),
tagType: z.enum(['managed', 'general']).default('managed') tagType: z.enum(['managed', 'general']).default('managed')

View File

@@ -1,7 +1,7 @@
import { readdir, rename, rm, stat } from 'node:fs/promises' import { readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path' import { basename, dirname, extname, join, relative } from 'node:path'
import { createError } from 'h3' 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 { getPostgresClient } from '../repositories/postgres-client'
import { import {
getPostThumbnailUrl, getPostThumbnailUrl,
@@ -506,17 +506,40 @@ const getMediaUsage = (url, posts, pages) => {
return [...postUsages, ...pageUsages] 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이 실제 사용처에서 쓰이는지 조회한다. * 미디어 URL이 실제 사용처에서 쓰이는지 조회한다.
* @param {string} url - 미디어 URL * @param {string} url - 미디어 URL
* @param {Array<Object>} posts - 게시물 목록 * @param {Array<Object>} posts - 게시물 목록
* @param {Array<Object>} pages - 페이지 목록 * @param {Array<Object>} pages - 페이지 목록
* @param {Object} siteSettings - 사이트 설정 * @param {Object} siteSettings - 사이트 설정
* @param {Array<Object>} tags - 태그 목록
* @returns {Array<Object>} 사용처 목록 * @returns {Array<Object>} 사용처 목록
*/ */
const getDirectMediaUsage = (url, posts, pages, siteSettings) => [ const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
...getMediaUsage(url, posts, pages), ...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 () => { export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot) const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap() const metadataMap = await getMediaMetadataMap()
const [posts, pages, siteSettings] = await Promise.all([ const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(), listAdminPosts(),
listPages(), listPages(),
getSiteSettings() getSiteSettings(),
listAdminTags()
]) ])
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url)) const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
const itemUrlSet = new Set(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 rawCategory = metadataMap[item.url]?.category ?? item.category
const category = normalizeStoredDisplayCategory(item.url, rawCategory) const category = normalizeStoredDisplayCategory(item.url, rawCategory)
const avatarOwner = avatarOwnerByUrl.get(item.url) || null 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 thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
const originalUrl = thumbnailOwnerByUrl.get(item.url) || '' const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
@@ -818,13 +842,14 @@ export const updateMediaCategories = async (urls, category) => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export const deleteMediaItem = async (url) => { export const deleteMediaItem = async (url) => {
const [posts, pages, siteSettings] = await Promise.all([ const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(), listAdminPosts(),
listPages(), listPages(),
getSiteSettings() getSiteSettings(),
listAdminTags()
]) ])
const usage = [ const usage = [
...getDirectMediaUsage(url, posts, pages, siteSettings), ...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings) ...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(), listAdminPosts(),
listPages(), listPages(),
getSiteSettings() getSiteSettings(),
listAdminTags()
]) ])
const usage = [ const usage = [
...getDirectMediaUsage(url, posts, pages, siteSettings), ...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings) ...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
] ]